Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions app/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions app/src/components/chat/QueuedFollowups.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
47 changes: 47 additions & 0 deletions app/src/components/chat/__tests__/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
54 changes: 54 additions & 0 deletions app/src/components/chat/__tests__/QueuedFollowups.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'لا توجد رسائل بعد',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'এখনো কোনো বার্তা নেই',
Expand Down
6 changes: 6 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'अभी कोई मैसेज नहीं',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '아직 메시지가 없습니다',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'Сообщений пока нет',
Expand Down
Loading
Loading