mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): queue follow-up messages while a response streams (#4050)
This commit is contained in:
@@ -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 ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { QueuedFollowup } from '../../store/chatRuntimeSlice';
|
||||
|
||||
export interface QueuedFollowupsProps {
|
||||
/** Follow-ups queued for the current thread while a turn is streaming. */
|
||||
items: QueuedFollowup[];
|
||||
/** Dismiss every queued follow-up (clears the backend run-queue too). */
|
||||
onClear: () => 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 (
|
||||
<div
|
||||
data-testid="queued-followups"
|
||||
className="mb-2 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-900/60 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-stone-500 dark:text-neutral-400">
|
||||
{t('chat.queuedFollowups.label')} · {items.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-queued-followups-clear"
|
||||
onClick={onClear}
|
||||
className="text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-coral-500 dark:hover:text-coral-400 transition-colors">
|
||||
{t('chat.queuedFollowups.clear')}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map(item => (
|
||||
<li
|
||||
key={item.message.id}
|
||||
className="truncate text-sm text-stone-700 dark:text-neutral-200"
|
||||
title={item.label}>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<QueuedFollowups items={[]} onClear={vi.fn()} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('lists queued follow-up labels with a count', () => {
|
||||
render(
|
||||
<QueuedFollowups
|
||||
items={[fup('a', 'ask about pricing'), fup('b', 'and the timeline')]}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(<QueuedFollowups items={[fup('a', 'photo.png', '')]} onClear={vi.fn()} />);
|
||||
// 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(<QueuedFollowups items={[fup('a', 'one')]} onClear={onClear} />);
|
||||
|
||||
fireEvent.click(screen.getByText('chat.queuedFollowups.clear'));
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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': 'لا توجد رسائل بعد',
|
||||
|
||||
@@ -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': 'এখনো কোনো বার্তা নেই',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': 'अभी कोई मैसेज नहीं',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '아직 메시지가 없습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Сообщений пока нет',
|
||||
|
||||
@@ -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': '暂无消息',
|
||||
|
||||
+163
-39
@@ -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<string, true> = {};
|
||||
|
||||
// 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<string, QueuedFollowup[]> = {};
|
||||
|
||||
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<string | null>(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<void> =>
|
||||
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')} →
|
||||
</button>
|
||||
)}
|
||||
{isSending && rustChat && (
|
||||
<div className="flex justify-start px-1">
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-cancel-generation"
|
||||
onClick={() => {
|
||||
if (selectedThreadId) void chatCancel(selectedThreadId);
|
||||
}}
|
||||
className="text-xs text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
) : 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 && (
|
||||
<div className="mb-2 flex justify-start px-1">
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-cancel-generation"
|
||||
onClick={() => {
|
||||
if (selectedThreadId) void chatCancel(selectedThreadId);
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{composer === 'mic-cloud' ? (
|
||||
<div className="flex flex-col items-center gap-3 py-1">
|
||||
<MicComposer
|
||||
@@ -2509,30 +2625,38 @@ const Conversations = ({
|
||||
/>
|
||||
</div>
|
||||
) : inputMode === 'text' ? (
|
||||
<ChatComposer
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
onSend={handleSendMessage}
|
||||
textInputRef={textInputRef}
|
||||
fileInputRef={fileInputRef}
|
||||
composerInteractionBlocked={composerInteractionBlocked}
|
||||
isSending={isSending}
|
||||
allowParallelSend={selectedThreadActive}
|
||||
attachments={attachments}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onRemoveAttachment={id => 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 && (
|
||||
<QueuedFollowups
|
||||
items={queuedFollowupsByThread[selectedThreadId] ?? []}
|
||||
onClear={() => void handleClearQueuedFollowups()}
|
||||
/>
|
||||
)}
|
||||
<ChatComposer
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
onSend={handleComposerSend}
|
||||
textInputRef={textInputRef}
|
||||
fileInputRef={fileInputRef}
|
||||
composerInteractionBlocked={composerInteractionBlocked}
|
||||
isSending={isSending}
|
||||
allowParallelSend={selectedThreadActive}
|
||||
attachments={attachments}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onRemoveAttachment={id => 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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
* previously-blocked lines that are now always rendered.
|
||||
*/
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
|
||||
import { threadApi } from '../../services/api/threadApi';
|
||||
import { chatSend } from '../../services/chatService';
|
||||
import { chatClearQueue, chatSend } from '../../services/chatService';
|
||||
import { CoreRpcError } from '../../services/coreRpcClient';
|
||||
import agentProfileReducer from '../../store/agentProfileSlice';
|
||||
import chatRuntimeReducer, {
|
||||
@@ -60,6 +60,7 @@ const mockUseOpenRouterFreeModels = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/chatService', () => ({
|
||||
chatCancel: vi.fn(),
|
||||
chatClearQueue: vi.fn().mockResolvedValue(0),
|
||||
chatSend: vi.fn().mockResolvedValue(undefined),
|
||||
subscribeChatEvents: vi.fn(() => () => {}),
|
||||
useRustChat: vi.fn(() => true),
|
||||
@@ -1197,16 +1198,16 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
// Advance another 80s (total elapsed 160s, well past the 120s
|
||||
// window). The tool-timeline dispatch should have re-armed the
|
||||
// timer at the 80s mark, so the silence timer is now at 80s of
|
||||
// its fresh 120s budget and has NOT fired. The pending guard
|
||||
// therefore still holds and Send stays disabled — proof the
|
||||
// rearm effect ran on a toolTimelineByThread change.
|
||||
// its fresh 120s budget and has NOT fired — the thread therefore
|
||||
// stays marked active. (The safety timeout would have dispatched
|
||||
// `clearThreadInferenceActive`, dropping it from `activeThreadIds`.)
|
||||
// We assert the active flag directly rather than the Send button:
|
||||
// a streaming thread now keeps the composer open for follow-up
|
||||
// queueing, so Send is intentionally enabled here.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(80_000);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'still typing while sub-agent runs' } });
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled();
|
||||
expect(store!.getState().thread.activeThreadIds[thread.id]).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -2122,3 +2123,132 @@ describe('Conversations — active-thread restore across in-app navigation', ()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversations — queued follow-ups while a turn streams', () => {
|
||||
// Reset shared mock call history + defaults per test so `toHaveBeenCalledWith`
|
||||
// assertions reflect only the current case (not bleed from an earlier one).
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
||||
vi.mocked(chatSend).mockResolvedValue(undefined);
|
||||
vi.mocked(chatClearQueue).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
// A selected thread that is actively streaming (`activeThreadIds`) keeps the
|
||||
// composer open for follow-up queueing — the placeholder flips to the
|
||||
// follow-up hint and a plain-Enter / Send submission queues a follow-up.
|
||||
async function renderStreamingConversation() {
|
||||
const thread = makeThread({ id: 'fup-thread', title: 'FUP Thread' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
||||
let store: ReturnType<typeof buildStore> | undefined;
|
||||
await act(async () => {
|
||||
store = await renderConversations({
|
||||
thread: { ...selectedThreadState(thread), activeThreadIds: { [thread.id]: true } },
|
||||
socket: socketState('connected'),
|
||||
});
|
||||
});
|
||||
const textarea = await screen.findByPlaceholderText(/Queue a follow-up/i);
|
||||
return { store, textarea, thread };
|
||||
}
|
||||
|
||||
it('queues a plain-Enter submission as a follow-up and lists it in the strip', async () => {
|
||||
const { textarea } = await renderStreamingConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'and the pricing?' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalledWith(expect.objectContaining({ queueMode: 'followup' }));
|
||||
});
|
||||
expect(await screen.findByText('and the pricing?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('queues via the Send button while a turn streams', async () => {
|
||||
const { textarea } = await renderStreamingConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'one more thing' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalledWith(expect.objectContaining({ queueMode: 'followup' }));
|
||||
});
|
||||
expect(await screen.findByText('one more thing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the queued follow-ups and the backend queue on Clear', async () => {
|
||||
const { textarea } = await renderStreamingConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'dismiss me' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
});
|
||||
|
||||
const strip = await screen.findByTestId('queued-followups');
|
||||
expect(within(strip).getByText('dismiss me')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(strip).getByText('Clear'));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(chatClearQueue).toHaveBeenCalledWith('fup-thread'));
|
||||
await waitFor(() => expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps the queued pills when the backend clear fails', async () => {
|
||||
vi.mocked(chatClearQueue).mockResolvedValueOnce(null);
|
||||
const { textarea } = await renderStreamingConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'still queued' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
});
|
||||
|
||||
const strip = await screen.findByTestId('queued-followups');
|
||||
await act(async () => {
|
||||
fireEvent.click(within(strip).getByText('Clear'));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(chatClearQueue).toHaveBeenCalledWith('fup-thread'));
|
||||
// Clear failed (null) → the backend will still dispatch them, so the pills
|
||||
// stay put instead of falsely showing the queue emptied.
|
||||
expect(screen.getByTestId('queued-followups')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('queued-followups')).getByText('still queued')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the draft intact when the follow-up send fails', async () => {
|
||||
vi.mocked(chatSend).mockRejectedValueOnce(new Error('send boom'));
|
||||
const { textarea } = await renderStreamingConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'keep me on failure' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
});
|
||||
|
||||
// Send rejected → no pill queued and the composer keeps the user's text so
|
||||
// they can retry instead of silently losing it.
|
||||
await waitFor(() => expect(chatSend).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('queued-followups')).not.toBeInTheDocument();
|
||||
expect(textarea).toHaveValue('keep me on failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
import {
|
||||
addInferenceResponse,
|
||||
addMessageLocal,
|
||||
clearThreadInferenceActive,
|
||||
createNewThread,
|
||||
generateThreadTitleIfNeeded,
|
||||
@@ -342,7 +343,35 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return { ...entry, displayName: formatted.title, detail: formatted.detail };
|
||||
};
|
||||
|
||||
const finishChatDoneTurn = (event: ChatDoneEvent, path: string) => {
|
||||
// When a turn ends, any follow-ups the user queued behind it are about to be
|
||||
// dispatched by the backend as fresh turns. Nothing else persists their
|
||||
// prompt — the web channel never writes user messages; the composer does
|
||||
// (`addMessageLocal` → `appendMessage`) — so append them to the transcript
|
||||
// now. Doing it here (after this turn's assistant reply was appended, before
|
||||
// `endInferenceTurn` clears the pills) keeps the append-log order correct:
|
||||
// user → assistant → queued follow-up. Without this the queued prompts are
|
||||
// lost on reload and the dispatched answer has no visible user message.
|
||||
const flushQueuedFollowups = async (threadId: string) => {
|
||||
const queued = store.getState().chatRuntime.queuedFollowupsByThread[threadId] ?? [];
|
||||
// Persist sequentially so the queued prompts land in the append-log in the
|
||||
// order the user queued them (concurrent dispatches would race), and
|
||||
// surface failures instead of dropping them silently. The stored message
|
||||
// carries the original content + attachment metadata, so the follow-up
|
||||
// persists identically to an interactive send.
|
||||
for (const item of queued) {
|
||||
try {
|
||||
await dispatch(addMessageLocal({ threadId, message: item.message })).unwrap();
|
||||
} catch (error) {
|
||||
rtLog('flush_followup_append_failed', {
|
||||
thread: threadId,
|
||||
message: item.message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const finishChatDoneTurn = async (event: ChatDoneEvent, path: string) => {
|
||||
rtLog('refresh_usage_counter', {
|
||||
thread: event.thread_id,
|
||||
request: event.request_id,
|
||||
@@ -356,6 +385,9 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
path,
|
||||
});
|
||||
refetchSnapshot();
|
||||
// Persist queued follow-ups (in order, after this turn's assistant reply)
|
||||
// and only then clear the queue + lifecycle.
|
||||
await flushQueuedFollowups(event.thread_id);
|
||||
dispatch(endInferenceTurn({ threadId: event.thread_id }));
|
||||
dispatch(clearThreadInferenceActive(event.thread_id));
|
||||
};
|
||||
@@ -1046,7 +1078,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
finishChatDoneTurn(event, 'proactive');
|
||||
await finishChatDoneTurn(event, 'proactive');
|
||||
})();
|
||||
return;
|
||||
}
|
||||
@@ -1081,7 +1113,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
finishChatDoneTurn(event, 'segment_reconcile');
|
||||
await finishChatDoneTurn(event, 'segment_reconcile');
|
||||
})();
|
||||
return;
|
||||
}
|
||||
@@ -1092,7 +1124,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
assistantMessage: event.full_response,
|
||||
})
|
||||
);
|
||||
finishChatDoneTurn(event, 'ordinary');
|
||||
void finishChatDoneTurn(event, 'ordinary');
|
||||
},
|
||||
onError: event => {
|
||||
const eventKey = `error:${event.thread_id}:${event.request_id ?? 'none'}:${event.error_type}`;
|
||||
@@ -1184,6 +1216,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
requestUsageRefresh();
|
||||
}
|
||||
|
||||
// The backend drains + dispatches queued follow-ups even when the turn
|
||||
// errored, so flush them to the transcript here too (otherwise their
|
||||
// prompts are lost). Mirrors the done path (sequential internally).
|
||||
void flushQueuedFollowups(event.thread_id);
|
||||
dispatch(endInferenceTurn({ threadId: event.thread_id }));
|
||||
dispatch(clearThreadInferenceActive(event.thread_id));
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { threadApi } from '../../services/api/threadApi';
|
||||
import { store } from '../../store';
|
||||
import {
|
||||
clearAllChatRuntime,
|
||||
enqueueFollowup,
|
||||
registerParallelRequest,
|
||||
resetSessionTokenUsage,
|
||||
} from '../../store/chatRuntimeSlice';
|
||||
@@ -390,6 +391,45 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
await waitFor(() => expect(mockRefetchSnapshot).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('flushes queued follow-ups into the transcript when a turn ends', async () => {
|
||||
const listeners = renderProvider();
|
||||
store.dispatch(
|
||||
enqueueFollowup({
|
||||
threadId: 't-fup',
|
||||
message: {
|
||||
id: 'f1',
|
||||
content: 'queued follow-up text',
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
label: 'queued follow-up text',
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
listeners.onDone?.({
|
||||
thread_id: 't-fup',
|
||||
request_id: 'r-fup',
|
||||
full_response: 'assistant reply',
|
||||
rounds_used: 1,
|
||||
total_input_tokens: 1,
|
||||
total_output_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// The queued prompt is persisted as a real user message (survives reload,
|
||||
// appended AFTER the assistant reply) and the pills are cleared.
|
||||
await waitFor(() =>
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledWith(
|
||||
't-fup',
|
||||
expect.objectContaining({ content: 'queued follow-up text', sender: 'user' })
|
||||
)
|
||||
);
|
||||
expect(store.getState().chatRuntime.queuedFollowupsByThread['t-fup']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('processes tool_call for different rounds as distinct events', () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { chatSend, subscribeChatEvents } from '../chatService';
|
||||
import { chatClearQueue, chatSend, subscribeChatEvents } from '../chatService';
|
||||
import { socketService } from '../socketService';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
@@ -428,3 +428,31 @@ describe('chatService.subscribeChatEvents', () => {
|
||||
expect(params.session_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatService.chatClearQueue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls the canonical queue-clear RPC with the thread id', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ dropped: 2 });
|
||||
|
||||
const dropped = await chatClearQueue('thread-9');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.channel_web_queue_clear',
|
||||
params: { thread_id: 'thread-9' },
|
||||
});
|
||||
expect(dropped).toBe(2);
|
||||
});
|
||||
|
||||
it('defaults to 0 dropped when the response omits the count', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({});
|
||||
expect(await chatClearQueue('thread-9')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null when the RPC throws so callers can keep the pills', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('rpc down'));
|
||||
expect(await chatClearQueue('thread-9')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1060,6 +1060,26 @@ export async function chatCancel(threadId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the run-queue (steer/followup/collect lanes) for a thread via core RPC.
|
||||
* Used when the user dismisses queued follow-ups so the backend drops them
|
||||
* instead of dispatching them after the current turn. Returns the number of
|
||||
* dropped messages on success, or `null` when the RPC fails — the caller must
|
||||
* distinguish these: on failure the backend queue is still intact and WILL
|
||||
* dispatch the follow-ups, so the UI must keep the pills rather than hide them.
|
||||
*/
|
||||
export async function chatClearQueue(threadId: string): Promise<number | null> {
|
||||
try {
|
||||
const res = await callCoreRpc<{ dropped?: number }>({
|
||||
method: 'openhuman.channel_web_queue_clear',
|
||||
params: { thread_id: threadId },
|
||||
});
|
||||
return res?.dropped ?? 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRustChat(): boolean {
|
||||
// Legacy name kept for compatibility with existing call sites.
|
||||
return true;
|
||||
|
||||
@@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import reducer, {
|
||||
beginInferenceTurn,
|
||||
clearAllChatRuntime,
|
||||
clearFollowupsForThread,
|
||||
clearQueueStatusForThread,
|
||||
clearRuntimeForThread,
|
||||
endInferenceTurn,
|
||||
enqueueFollowup,
|
||||
removeFollowup,
|
||||
setQueueStatusForThread,
|
||||
} from '../chatRuntimeSlice';
|
||||
|
||||
@@ -113,3 +116,74 @@ describe('chatRuntimeSlice — queue status', () => {
|
||||
expect(state.queueStatusByThread['thread-1']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatRuntimeSlice — queued follow-ups', () => {
|
||||
const enq = (threadId: string, id: string, text: string) =>
|
||||
enqueueFollowup({
|
||||
threadId,
|
||||
message: {
|
||||
id,
|
||||
content: text,
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
label: text,
|
||||
});
|
||||
|
||||
it('enqueues follow-ups in order per thread', () => {
|
||||
let state = reducer(undefined, enq('t1', 'a', 'first'));
|
||||
state = reducer(state, enq('t1', 'b', 'second'));
|
||||
|
||||
expect(state.queuedFollowupsByThread['t1'].map(f => f.message.id)).toEqual(['a', 'b']);
|
||||
expect(state.queuedFollowupsByThread['t1'].map(f => f.label)).toEqual(['first', 'second']);
|
||||
expect(state.queuedFollowupsByThread['t1'][0].message.content).toBe('first');
|
||||
});
|
||||
|
||||
it('keeps follow-up queues isolated per thread', () => {
|
||||
let state = reducer(undefined, enq('t1', 'a', 'one'));
|
||||
state = reducer(state, enq('t2', 'b', 'two'));
|
||||
|
||||
expect(state.queuedFollowupsByThread['t1']).toHaveLength(1);
|
||||
expect(state.queuedFollowupsByThread['t2']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removeFollowup drops one entry by message id and prunes empty buckets', () => {
|
||||
let state = reducer(undefined, enq('t1', 'a', 'one'));
|
||||
state = reducer(state, enq('t1', 'b', 'two'));
|
||||
|
||||
state = reducer(state, removeFollowup({ threadId: 't1', id: 'a' }));
|
||||
expect(state.queuedFollowupsByThread['t1'].map(f => f.message.id)).toEqual(['b']);
|
||||
|
||||
state = reducer(state, removeFollowup({ threadId: 't1', id: 'b' }));
|
||||
expect(state.queuedFollowupsByThread['t1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clearFollowupsForThread drops all entries for the thread', () => {
|
||||
let state = reducer(undefined, enq('t1', 'a', 'one'));
|
||||
state = reducer(state, clearFollowupsForThread({ threadId: 't1' }));
|
||||
|
||||
expect(state.queuedFollowupsByThread['t1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('endInferenceTurn clears the thread follow-up queue (it is being dispatched)', () => {
|
||||
let state = reducer(undefined, enq('t1', 'a', 'one'));
|
||||
state = reducer(state, beginInferenceTurn({ threadId: 't1' }));
|
||||
state = reducer(state, endInferenceTurn({ threadId: 't1' }));
|
||||
|
||||
expect(state.queuedFollowupsByThread['t1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clearRuntimeForThread and clearAllChatRuntime drop follow-up queues', () => {
|
||||
let state = reducer(undefined, enq('t1', 'a', 'one'));
|
||||
state = reducer(state, enq('t2', 'b', 'two'));
|
||||
|
||||
const perThread = reducer(state, clearRuntimeForThread({ threadId: 't1' }));
|
||||
expect(perThread.queuedFollowupsByThread['t1']).toBeUndefined();
|
||||
expect(perThread.queuedFollowupsByThread['t2']).toBeDefined();
|
||||
|
||||
const all = reducer(state, clearAllChatRuntime());
|
||||
expect(Object.keys(all.queuedFollowupsByThread)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/tool
|
||||
import debug from 'debug';
|
||||
|
||||
import { threadApi } from '../services/api/threadApi';
|
||||
import type { ThreadMessage } from '../types/thread';
|
||||
import type {
|
||||
AgentRun,
|
||||
PersistedSubagentActivity,
|
||||
@@ -302,6 +303,15 @@ interface ChatRuntimeState {
|
||||
artifactsByThread: Record<string, ArtifactSnapshot[]>;
|
||||
sessionTokenUsage: SessionTokenUsage;
|
||||
queueStatusByThread: Record<string, QueueStatus>;
|
||||
/**
|
||||
* Follow-up messages the user submitted while a turn was still streaming
|
||||
* (queued via `queueMode: 'followup'`). The backend dispatches them as fresh
|
||||
* turns once the current turn finishes; these entries are purely the
|
||||
* optimistic UI surface so the user can see what they queued and clear it.
|
||||
* Cleared per-thread on turn end (the queued texts then arrive as real
|
||||
* messages on their dispatched turns).
|
||||
*/
|
||||
queuedFollowupsByThread: Record<string, QueuedFollowup[]>;
|
||||
}
|
||||
|
||||
/** Snapshot of the active-run queue depth per lane. */
|
||||
@@ -313,6 +323,22 @@ export interface QueueStatus {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A follow-up message queued from the composer while a turn was streaming. */
|
||||
export interface QueuedFollowup {
|
||||
/**
|
||||
* The full user message, built exactly like a normal send (content +
|
||||
* attachment metadata). It is persisted verbatim when the turn ends so the
|
||||
* follow-up lands in the transcript identically to an interactive send.
|
||||
* `message.id` doubles as the React key / removal handle.
|
||||
*/
|
||||
message: ThreadMessage;
|
||||
/**
|
||||
* Display label for the pill — the message text, or the attachment file
|
||||
* names for an attachments-only follow-up, so the row is never blank.
|
||||
*/
|
||||
label: string;
|
||||
}
|
||||
|
||||
const initialState: ChatRuntimeState = {
|
||||
inferenceStatusByThread: {},
|
||||
streamingAssistantByThread: {},
|
||||
@@ -332,6 +358,7 @@ const initialState: ChatRuntimeState = {
|
||||
lastTurnOutputTokens: 0,
|
||||
},
|
||||
queueStatusByThread: {},
|
||||
queuedFollowupsByThread: {},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -814,6 +841,31 @@ const chatRuntimeSlice = createSlice({
|
||||
clearQueueStatusForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.queueStatusByThread[action.payload.threadId];
|
||||
},
|
||||
/** Append a follow-up the user queued while a turn was streaming. */
|
||||
enqueueFollowup: (
|
||||
state,
|
||||
action: PayloadAction<{ threadId: string; message: ThreadMessage; label: string }>
|
||||
) => {
|
||||
const { threadId, message, label } = action.payload;
|
||||
const bucket = state.queuedFollowupsByThread[threadId] ?? [];
|
||||
bucket.push({ message, label });
|
||||
state.queuedFollowupsByThread[threadId] = bucket;
|
||||
},
|
||||
/** Drop a single queued follow-up by message id (e.g. the user removed it). */
|
||||
removeFollowup: (state, action: PayloadAction<{ threadId: string; id: string }>) => {
|
||||
const bucket = state.queuedFollowupsByThread[action.payload.threadId];
|
||||
if (!bucket) return;
|
||||
const next = bucket.filter(item => item.message.id !== action.payload.id);
|
||||
if (next.length) {
|
||||
state.queuedFollowupsByThread[action.payload.threadId] = next;
|
||||
} else {
|
||||
delete state.queuedFollowupsByThread[action.payload.threadId];
|
||||
}
|
||||
},
|
||||
/** Drop all queued follow-ups for a thread (turn end / explicit clear). */
|
||||
clearFollowupsForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.queuedFollowupsByThread[action.payload.threadId];
|
||||
},
|
||||
beginInferenceTurn: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
state.inferenceTurnLifecycleByThread[action.payload.threadId] = 'started';
|
||||
},
|
||||
@@ -824,6 +876,10 @@ const chatRuntimeSlice = createSlice({
|
||||
},
|
||||
endInferenceTurn: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.inferenceTurnLifecycleByThread[action.payload.threadId];
|
||||
// The turn finished, so any follow-ups queued behind it are now being
|
||||
// dispatched by the backend — drop the optimistic pills; the queued
|
||||
// texts reappear as real messages on their dispatched turns.
|
||||
delete state.queuedFollowupsByThread[action.payload.threadId];
|
||||
},
|
||||
clearRuntimeForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.inferenceStatusByThread[action.payload.threadId];
|
||||
@@ -842,6 +898,7 @@ const chatRuntimeSlice = createSlice({
|
||||
delete state.inferenceTurnLifecycleByThread[action.payload.threadId];
|
||||
delete state.pendingApprovalByThread[action.payload.threadId];
|
||||
delete state.queueStatusByThread[action.payload.threadId];
|
||||
delete state.queuedFollowupsByThread[action.payload.threadId];
|
||||
// Note: artifactsByThread intentionally NOT cleared here. The
|
||||
// ArtifactCard renders inline in the message timeline, so the
|
||||
// snapshot needs to survive turn boundaries — historic artifacts
|
||||
@@ -859,6 +916,7 @@ const chatRuntimeSlice = createSlice({
|
||||
state.pendingApprovalByThread = {};
|
||||
state.artifactsByThread = {};
|
||||
state.queueStatusByThread = {};
|
||||
state.queuedFollowupsByThread = {};
|
||||
},
|
||||
recordChatTurnUsage: (
|
||||
state,
|
||||
@@ -998,6 +1056,9 @@ export const {
|
||||
removeArtifactForThread,
|
||||
setQueueStatusForThread,
|
||||
clearQueueStatusForThread,
|
||||
enqueueFollowup,
|
||||
removeFollowup,
|
||||
clearFollowupsForThread,
|
||||
beginInferenceTurn,
|
||||
markInferenceTurnStreaming,
|
||||
endInferenceTurn,
|
||||
|
||||
Reference in New Issue
Block a user