From 689ef8123ca4ee705e996add80069ff99ca3909c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCsam?= <56267780+hgunduzoglu@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:09:17 +0300 Subject: [PATCH] feat(chat): queue follow-up messages while a response streams (#4050) --- 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 | 54 +++++ app/src/lib/i18n/ar.ts | 4 + app/src/lib/i18n/bn.ts | 5 + app/src/lib/i18n/de.ts | 6 + app/src/lib/i18n/en.ts | 5 + app/src/lib/i18n/es.ts | 5 + app/src/lib/i18n/fr.ts | 5 + app/src/lib/i18n/hi.ts | 5 + app/src/lib/i18n/id.ts | 5 + app/src/lib/i18n/it.ts | 5 + app/src/lib/i18n/ko.ts | 4 + app/src/lib/i18n/pl.ts | 5 + app/src/lib/i18n/pt.ts | 5 + app/src/lib/i18n/ru.ts | 5 + app/src/lib/i18n/zh-CN.ts | 4 + app/src/pages/Conversations.tsx | 202 ++++++++++++++---- .../__tests__/Conversations.render.test.tsx | 148 ++++++++++++- app/src/providers/ChatRuntimeProvider.tsx | 44 +++- .../__tests__/ChatRuntimeProvider.test.tsx | 40 ++++ .../services/__tests__/chatService.test.ts | 30 ++- app/src/services/chatService.ts | 20 ++ .../__tests__/chatRuntimeSlice.queue.test.ts | 74 +++++++ app/src/store/chatRuntimeSlice.ts | 61 ++++++ 26 files changed, 806 insertions(+), 64 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 e340397e7..10f20d827 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 7773e5982..f15249bf4 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 000000000..2fc551841 --- /dev/null +++ b/app/src/components/chat/__tests__/QueuedFollowups.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { QueuedFollowup } from '../../../store/chatRuntimeSlice'; +import QueuedFollowups from '../QueuedFollowups'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const fup = (id: string, label: string, content = label): QueuedFollowup => ({ + message: { + id, + content, + type: 'text', + extraMetadata: {}, + sender: 'user', + createdAt: '2026-01-01T00:00:00.000Z', + }, + label, +}); + +describe('QueuedFollowups', () => { + it('renders nothing when there are no queued items', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('lists queued follow-up labels 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('falls back to the attachment-name label for an attachments-only follow-up', () => { + render(); + // content is empty (attachments only) but the label keeps the row non-blank. + expect(screen.getByText('photo.png')).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 a34a66002..35e399fbd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -459,6 +459,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'كيف يمكنني مساعدتك اليوم؟', 'chat.send': 'إرسال الرسالة', 'chat.parallelBranchHint': 'فرع متوازٍ — ⌘/Ctrl+Enter للإرسال', + 'chat.followupHint': 'أضِف متابعة إلى القائمة — تُرسَل بعد هذا الرد · ⌘/Ctrl+Enter لفرع متوازٍ', + 'chat.queuedFollowups.label': 'متابعات في قائمة الانتظار', + 'chat.queuedFollowups.clear': 'مسح', + 'chat.queuedFollowups.clearFailed': 'تعذّر مسح القائمة — حاول مرة أخرى.', 'chat.parallelBranchLabel': 'فرع متوازٍ', 'chat.thinking': 'جارٍ التفكير...', 'chat.noMessages': 'لا توجد رسائل بعد', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 25c65fe95..ec1281471 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -466,6 +466,11 @@ const messages: TranslationMap = { 'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?', 'chat.send': 'বার্তা পাঠান', 'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন — পাঠাতে ⌘/Ctrl+Enter', + 'chat.followupHint': + 'একটি ফলো-আপ সারিবদ্ধ করুন — এই উত্তরের পরে পাঠানো হবে · সমান্তরাল শাখার জন্য ⌘/Ctrl+Enter', + 'chat.queuedFollowups.label': 'সারিবদ্ধ ফলো-আপ', + 'chat.queuedFollowups.clear': 'সাফ করুন', + 'chat.queuedFollowups.clearFailed': 'সারি সাফ করা যায়নি — আবার চেষ্টা করুন।', 'chat.parallelBranchLabel': 'সমান্তরাল শাখা', 'chat.thinking': 'ভাবছে...', 'chat.noMessages': 'এখনো কোনো বার্তা নেই', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index a8d018ed2..a39991f92 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -480,6 +480,12 @@ 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.queuedFollowups.clearFailed': + 'Warteschlange konnte nicht geleert werden – bitte erneut versuchen.', '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 89568271d..ff1df7c7a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -489,6 +489,11 @@ 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.queuedFollowups.clearFailed': "Couldn't clear the queue — try again.", '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 882146007..41d3d90cd 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -479,6 +479,11 @@ 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.queuedFollowups.clearFailed': 'No se pudo vaciar la cola: inténtalo de nuevo.', '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 fdca1c8ab..c4ccbdd0c 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -480,6 +480,11 @@ 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.queuedFollowups.clearFailed': 'Impossible de vider la file — réessayez.', '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 77bf2d9b3..be87f6ab9 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -465,6 +465,11 @@ const messages: TranslationMap = { 'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?', 'chat.send': 'मैसेज भेजें', 'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें — भेजने के लिए ⌘/Ctrl+Enter', + 'chat.followupHint': + 'फ़ॉलो-अप कतार में लगाएँ — इस उत्तर के बाद भेजा जाएगा · समानांतर शाखा के लिए ⌘/Ctrl+Enter', + 'chat.queuedFollowups.label': 'कतारबद्ध फ़ॉलो-अप', + 'chat.queuedFollowups.clear': 'साफ़ करें', + 'chat.queuedFollowups.clearFailed': 'कतार साफ़ नहीं हो सकी — फिर से प्रयास करें।', 'chat.parallelBranchLabel': 'समानांतर शाखा', 'chat.thinking': 'सोच रहा है...', 'chat.noMessages': 'अभी कोई मैसेज नहीं', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e6e29dc4f..49d4b859c 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -468,6 +468,11 @@ 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.queuedFollowups.clearFailed': 'Gagal mengosongkan antrean — coba lagi.', '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 2d21172e5..6f8635f91 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -476,6 +476,11 @@ 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.queuedFollowups.clearFailed': 'Impossibile svuotare la coda — riprova.', '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 45eaa36b6..0268d14de 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.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.queuedFollowups.clearFailed': '대기열을 지우지 못했습니다 — 다시 시도하세요.', 'chat.parallelBranchLabel': '병렬 분기', 'chat.thinking': '생각 중...', 'chat.noMessages': '아직 메시지가 없습니다', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c945228fb..170da6f90 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -471,6 +471,11 @@ 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.queuedFollowups.clearFailed': 'Nie udało się wyczyścić kolejki — spróbuj ponownie.', '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 681142d68..f46ae3c14 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -479,6 +479,11 @@ 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.queuedFollowups.clearFailed': 'Não foi possível limpar a fila — tente novamente.', '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 2dc065ba4..944be574e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -472,6 +472,11 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Чем я могу помочь тебе сегодня?', 'chat.send': 'Отправить сообщение', 'chat.parallelBranchHint': 'Введите параллельную ветку — ⌘/Ctrl+Enter для отправки', + 'chat.followupHint': + 'Поставить продолжение в очередь — отправится после этого ответа · ⌘/Ctrl+Enter для параллельной ветки', + 'chat.queuedFollowups.label': 'Сообщения в очереди', + 'chat.queuedFollowups.clear': 'Очистить', + 'chat.queuedFollowups.clearFailed': 'Не удалось очистить очередь — попробуйте ещё раз.', '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 6aa0b6d26..0f5264164 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -449,6 +449,10 @@ const messages: TranslationMap = { 'chat.typeMessage': '今天我能帮您做什么?', 'chat.send': '发送', 'chat.parallelBranchHint': '输入并行分支 — ⌘/Ctrl+Enter 发送', + 'chat.followupHint': '将后续消息加入队列 — 将在本次回复后发送 · ⌘/Ctrl+Enter 开启并行分支', + 'chat.queuedFollowups.label': '排队的后续消息', + 'chat.queuedFollowups.clear': '清除', + 'chat.queuedFollowups.clearFailed': '无法清空队列 — 请重试。', 'chat.parallelBranchLabel': '并行分支', 'chat.thinking': '思考中...', 'chat.noMessages': '暂无消息', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index edd389a77..9e64deb25 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,9 +43,12 @@ import { } from '../store/agentProfileSlice'; import { beginInferenceTurn, + clearFollowupsForThread, clearRuntimeForThread, + enqueueFollowup, fetchAndHydrateTurnState, markSubagentCancelled, + type QueuedFollowup, registerParallelRequest, setTaskBoardForThread, setToolTimelineForThread, @@ -154,6 +158,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 +319,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 +1110,100 @@ 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. We do NOT insert it into the transcript now — + // appending it mid-stream would persist it BEFORE the in-flight assistant + // reply (the conversation store is an append log), so the prompt would show + // out of order on reload. Instead we record a queued-follow-up pill; the pill + // is flushed into the transcript (persisted, in order, after the assistant + // reply) when the turn ends — see `ChatRuntimeProvider`'s done/error paths. + const handleSendFollowup = async (text?: string) => { + if (!rustChat || !selectedThreadId) return; + const threadId = selectedThreadId; + const normalized = (text ?? inputValue).trim(); + const pendingAttachments = attachments.slice(); + if (!normalized && pendingAttachments.length === 0) return; + + const modelOverride = + agentProfiles.find(p => p.id === selectedAgentProfileId)?.modelOverride ?? CHAT_MODEL_HINT; + const messageText = buildMessageWithAttachments(normalized, pendingAttachments); + // Build the full user message exactly like a normal send (content + + // attachment metadata) so the follow-up persists identically when it is + // flushed into the transcript on turn end. Guard `crypto.randomUUID` like + // the rest of the codebase (threadSlice) for runtimes that lack it. + const messageId = `msg_${ + globalThis.crypto?.randomUUID + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + }`; + const followupMessage: ThreadMessage = { + id: messageId, + content: normalized, + type: 'text', + extraMetadata: + pendingAttachments.length > 0 + ? { + attachmentCount: pendingAttachments.length, + attachmentNames: pendingAttachments.map(a => a.file.name), + attachmentKinds: pendingAttachments.map(a => a.kind), + attachmentDataUris: pendingAttachments + .filter(a => a.kind === 'image') + .map(a => a.previewUri ?? a.dataUri), + attachmentCompressed: pendingAttachments.map(a => a.compressed), + } + : {}, + sender: 'user', + createdAt: new Date().toISOString(), + }; + // Never render a blank pill for an attachments-only follow-up: fall back to + // the attachment file names as the label. + const label = normalized || pendingAttachments.map(a => a.file.name).join(', '); + + setSendError(null); + setAttachError(null); + + try { + await chatSend({ + threadId, + message: messageText, + model: modelOverride, + profileId: selectedAgentProfileId, + locale: uiLocale, + queueMode: 'followup', + }); + // Only clear the composer once the backend has accepted the queue, so a + // failed send leaves the user's draft + attachments intact to retry. + setInputValue(''); + setAttachments([]); + dispatch(enqueueFollowup({ threadId, message: followupMessage, label })); + 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 FIRST and only drop the local pills if it succeeded — on failure + // the backend still holds (and will dispatch) the follow-ups, so keep the + // pills and surface the error rather than falsely showing them removed. + const handleClearQueuedFollowups = async () => { + if (!selectedThreadId) return; + const threadId = selectedThreadId; + const dropped = await chatClearQueue(threadId); + if (dropped === null) { + setSendError(chatSendError('cloud_send_failed', t('chat.queuedFollowups.clearFailed'))); + return; + } + dispatch(clearFollowupsForThread({ 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 +1428,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 +2336,6 @@ const Conversations = ({ {t('conversations.agentTaskInsights.viewProcessSource')} → )} - {isSending && rustChat && ( -
- -
- )}
) : isNewWindow ? ( @@ -2495,6 +2593,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} + /> + ) : (