feat(chat): queue follow-up messages while a response streams (#4050)

This commit is contained in:
Hüsam
2026-06-24 10:09:17 -07:00
committed by GitHub
parent 08a8c8160e
commit 689ef8123c
26 changed files with 806 additions and 64 deletions
+21 -11
View File
@@ -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);
});
});
+4
View File
@@ -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': 'لا توجد رسائل بعد',
+5
View File
@@ -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': 'এখনো কোনো বার্তা নেই',
+6
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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",
+5
View File
@@ -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': 'अभी कोई मैसेज नहीं',
+5
View File
@@ -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',
+5
View File
@@ -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',
+4
View File
@@ -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': '아직 메시지가 없습니다',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': 'Сообщений пока нет',
+4
View File
@@ -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
View File
@@ -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');
});
});
+40 -4
View File
@@ -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();
+29 -1
View File
@@ -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();
});
});
+20
View File
@@ -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);
});
});
+61
View File
@@ -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,