From af103d8b09fa9c9dfcf4ea8ac3093fbfc6040198 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Jun 2026 14:23:24 +0100 Subject: [PATCH] Queue Assistant follow-up sends --- .../v6/internal/subsystems/ai-runtime.md | 16 +- .../src/components/AI/Chat/MessageItem.tsx | 19 +- .../AI/Chat/__tests__/AIChat.test.tsx | 48 +++- .../AI/Chat/__tests__/MessageItem.test.tsx | 16 ++ .../AI/Chat/__tests__/useChat.test.ts | 254 +++++++++++++----- .../src/components/AI/Chat/hooks/useChat.ts | 180 +++++++++++-- .../src/components/AI/Chat/index.tsx | 46 +++- .../src/components/AI/Chat/types.ts | 1 + 8 files changed, 467 insertions(+), 113 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 82d454dfd..4cbaa32ee 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -82,12 +82,16 @@ runtime cost control, and shared AI transport surfaces. provider error must keep typed text and focus while blocking dispatch until the route is rechecked successfully or the operator chooses a ready alternative. - Operator interruption is likewise chat-runtime state: Stop and replacement - sends must abort the active stream, clear pending tool/approval/question - affordances, preserve any partial model text, return focus to the composer, - and render a neutral transcript marker rather than persisting synthetic - assistant answer text or surfacing the interruption as a retryable provider - failure. + Follow-up sends during an active Assistant response are chat-runtime queue + state by default. The drawer must accept and echo the user's follow-up as a + queued user turn without aborting or replacing the active model stream, must + show the queued count as composer-adjacent status, and must drain queued + turns in order only after the active stream becomes idle. Stop is the + explicit interruption path: it must abort the active stream, clear queued + follow-ups and pending tool/approval/question affordances, preserve any + partial model text, return focus to the composer, and render a neutral + transcript marker rather than persisting synthetic assistant answer text or + surfacing the interruption as a retryable provider failure. Assistant output hygiene is part of the same boundary: provider reasoning and raw serialized tool-call artifacts must never render as assistant transcript prose. Reasoning/thinking deltas may update neutral progress diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index e36564dca..a141bc0f5 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -1,6 +1,7 @@ import { Component, Show, For, Switch, Match, createMemo, createSignal } from 'solid-js'; import CheckIcon from 'lucide-solid/icons/check'; import CircleAlertIcon from 'lucide-solid/icons/circle-alert'; +import ClockIcon from 'lucide-solid/icons/clock'; import CopyIcon from 'lucide-solid/icons/copy'; import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw'; import SparklesIcon from 'lucide-solid/icons/sparkles'; @@ -40,6 +41,7 @@ const markdownClass = */ export const MessageItem: Component = (props) => { const isUser = () => props.message.role === 'user'; + const isQueuedUserMessage = () => isUser() && props.message.delivery === 'queued'; // Group stream events into display blocks. Content collapses into a single // block even when a reasoning model interleaves hidden thinking deltas, so @@ -117,8 +119,23 @@ export const MessageItem: Component = (props) => {
{/* User message - compact bubble */} -
+

{props.message.content}

+ +
+
+
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index fb532802a..d029f977b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -20,8 +20,12 @@ const { sessionId: vi.fn(() => ''), model: vi.fn(() => ''), setModel: vi.fn(), + queuedFollowUps: vi.fn(() => []), + queuedFollowUpCount: vi.fn(() => 0), sendMessage: vi.fn().mockResolvedValue(true), stop: vi.fn(), + cancelQueuedFollowUp: vi.fn(), + clearQueuedFollowUps: vi.fn(), clearMessages: vi.fn(), loadSession: vi.fn().mockResolvedValue(true), newSession: vi.fn().mockResolvedValue(true), @@ -311,6 +315,8 @@ beforeEach(() => { mockChat.isLoading.mockReturnValue(false); mockChat.sessionId.mockReturnValue(''); mockChat.model.mockReturnValue(''); + mockChat.queuedFollowUps.mockReturnValue([]); + mockChat.queuedFollowUpCount.mockReturnValue(0); mockChat.sendMessage.mockResolvedValue(true); mockByType.mockReturnValue([]); mockResources.mockReturnValue([]); @@ -977,13 +983,14 @@ describe('AIChat', () => { expect(mockChat.sendMessage).not.toHaveBeenCalled(); }); - it('sends a replacement message when chat is loading', () => { + it('queues a follow-up message when chat is loading', () => { mockChat.isLoading.mockReturnValue(true); renderChat(); const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); fireEvent.input(textarea, { target: { value: 'hello' } }); fireEvent.keyDown(textarea, { key: 'Enter' }); expect(mockChat.sendMessage).toHaveBeenCalledWith('hello', undefined, undefined); + expect(textarea).toHaveValue(''); }); it('clears input after successful submit', () => { @@ -1022,7 +1029,7 @@ describe('AIChat', () => { mockChat.isLoading.mockReturnValue(true); renderChat(); expect(screen.getByTitle('Stop')).toBeInTheDocument(); - expect(screen.getByTitle('Send')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Queue follow-up' })).toBeInTheDocument(); }); it('calls chat.stop when stop button is clicked', () => { @@ -1060,6 +1067,19 @@ describe('AIChat', () => { await waitFor(() => expect(document.activeElement).toBe(textarea)); }); + + it('shows queued follow-up count and clears queued follow-ups', () => { + mockChat.queuedFollowUpCount.mockReturnValue(2); + renderChat(); + + expect(screen.getByRole('status', { name: 'Queued follow-up messages' })).toHaveTextContent( + '2 follow-ups queued', + ); + + fireEvent.click(screen.getByRole('button', { name: 'Clear queued follow-up messages' })); + + expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1); + }); }); // ── Control level ──────────────────────────────────────────────────── @@ -2494,6 +2514,30 @@ describe('AIChat', () => { expect(screen.getByText('Generating response...')).toBeInTheDocument(); }); + it('tracks the active assistant status when a queued user turn is the last message', () => { + mockChat.isLoading.mockReturnValue(true); + mockChat.queuedFollowUpCount.mockReturnValue(1); + mockChat.messages.mockReturnValue([ + { + id: 'msg-1', + role: 'assistant' as const, + content: 'partial response', + timestamp: new Date(), + isStreaming: true, + }, + { + id: 'msg-2', + role: 'user' as const, + content: 'follow up', + timestamp: new Date(), + delivery: 'queued', + }, + ]); + renderChat(); + expect(screen.getByText('Generating response...')).toBeInTheDocument(); + expect(screen.getByText('1 follow-up queued')).toBeInTheDocument(); + }); + it('shows no status indicator when not loading', () => { mockChat.isLoading.mockReturnValue(false); renderChat(); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index 039a78c0f..ba6bbf2ae 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -125,6 +125,22 @@ describe('MessageItem', () => { const p = screen.getByText('raw text here'); expect(p.tagName).toBe('P'); }); + + it('renders queued user messages with a status marker', () => { + render(() => ( + + )); + + expect(screen.getByText('follow up after this')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Queued'); + }); }); describe('error block', () => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 3cd73f51d..dcbc784f4 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -79,9 +79,13 @@ describe('useChat', () => { expect(chat.isLoading()).toBe(false); expect(chat.sessionId()).toBe(''); expect(chat.model()).toBe(''); + expect(chat.queuedFollowUps()).toEqual([]); + expect(chat.queuedFollowUpCount()).toBe(0); expect(typeof chat.sendMessage).toBe('function'); expect(typeof chat.retryMessage).toBe('function'); expect(typeof chat.stop).toBe('function'); + expect(typeof chat.cancelQueuedFollowUp).toBe('function'); + expect(typeof chat.clearQueuedFollowUps).toBe('function'); expect(typeof chat.clearMessages).toBe('function'); expect(typeof chat.loadSession).toBe('function'); expect(typeof chat.newSession).toBe('function'); @@ -352,66 +356,10 @@ describe('useChat', () => { dispose(); }); - it('aborts current stream when sending mid-stream', async () => { - // First call: capture signal so we can verify it was aborted - let capturedSignal: AbortSignal | undefined; - const abortError = new Error('Aborted'); - abortError.name = 'AbortError'; - mockAbortSession.mockResolvedValue(undefined); - - mockChat - .mockImplementationOnce( - ( - _p: string, - _s: string, - _m: string | undefined, - _onEvent: (e: StreamEvent) => void, - signal?: AbortSignal, - ) => { - capturedSignal = signal; - // Simulate the abort path: when aborted, reject with AbortError - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(abortError)); - }); - }, - ) - .mockResolvedValueOnce(undefined); - - const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' })); - - // Start first message (will hang until aborted) - const p1 = chat.sendMessage('first'); - await new Promise((r) => setTimeout(r, 10)); - - expect(chat.isLoading()).toBe(true); - - // Send second message which should abort the first - const p2 = chat.sendMessage('second'); - - // First call should have been aborted - expect(capturedSignal?.aborted).toBe(true); - expect(mockAbortSession).toHaveBeenCalledWith('sess'); - - const result1 = await p1; - expect(result1).toBe(false); // Aborted → returns false - - await p2; - - // First assistant message should be marked non-streaming after abort - const msgs = chat.messages(); - const firstAssistant = msgs.find((m) => m.role === 'assistant'); - expect(firstAssistant?.isStreaming).toBe(false); - expect(firstAssistant?.interruption).toBe('replaced'); - expect(firstAssistant?.content).toBe(''); - dispose(); - }); - - it('keeps replacement send loading state isolated from the aborted request', async () => { + it('queues follow-up sends mid-stream without aborting the active response', async () => { let firstSignal: AbortSignal | undefined; + let resolveFirst!: () => void; let resolveSecond!: () => void; - const abortError = new Error('Aborted'); - abortError.name = 'AbortError'; - mockAbortSession.mockResolvedValue(undefined); mockChat .mockImplementationOnce( @@ -423,10 +371,8 @@ describe('useChat', () => { signal?: AbortSignal, ) => { firstSignal = signal; - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - queueMicrotask(() => reject(abortError)); - }); + return new Promise((resolve) => { + resolveFirst = resolve; }); }, ) @@ -441,20 +387,107 @@ describe('useChat', () => { const first = chat.sendMessage('first'); await new Promise((r) => setTimeout(r, 0)); - const second = chat.sendMessage('second'); + const queued = await chat.sendMessage('second'); + + expect(queued).toBe(true); + expect(firstSignal?.aborted).toBe(false); + expect(mockAbortSession).not.toHaveBeenCalled(); + expect(mockChat).toHaveBeenCalledTimes(1); + expect(chat.queuedFollowUpCount()).toBe(1); + + const queuedUser = chat.messages().find((message) => message.content === 'second'); + expect(queuedUser).toMatchObject({ + role: 'user', + delivery: 'queued', + }); + + resolveFirst(); + await first; await new Promise((r) => setTimeout(r, 0)); - expect(firstSignal?.aborted).toBe(true); - await expect(first).resolves.toBe(false); + expect(mockChat).toHaveBeenCalledTimes(2); + expect(mockChat.mock.calls[1][0]).toBe('second'); + expect(chat.queuedFollowUpCount()).toBe(0); + expect(chat.messages().find((message) => message.content === 'second')).toMatchObject({ + delivery: 'sent', + }); expect(chat.isLoading()).toBe(true); - expect(chat.messages().filter((message) => message.role === 'assistant')).toHaveLength(2); resolveSecond(); - await second; + await new Promise((r) => setTimeout(r, 0)); expect(chat.isLoading()).toBe(false); dispose(); }); + it('preserves queued follow-up order and drains one turn at a time', async () => { + const resolvers: Array<() => void> = []; + mockChat.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' })); + const first = chat.sendMessage('first'); + await new Promise((r) => setTimeout(r, 0)); + + await chat.sendMessage('second'); + await chat.sendMessage('third'); + + expect(mockChat).toHaveBeenCalledTimes(1); + expect(chat.queuedFollowUpCount()).toBe(2); + + resolvers[0](); + await first; + await new Promise((r) => setTimeout(r, 0)); + + expect(mockChat).toHaveBeenCalledTimes(2); + expect(mockChat.mock.calls[1][0]).toBe('second'); + expect(chat.queuedFollowUpCount()).toBe(1); + + resolvers[1](); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockChat).toHaveBeenCalledTimes(3); + expect(mockChat.mock.calls[2][0]).toBe('third'); + expect(chat.queuedFollowUpCount()).toBe(0); + + resolvers[2](); + await new Promise((r) => setTimeout(r, 0)); + expect(chat.isLoading()).toBe(false); + dispose(); + }); + + it('cancels a queued follow-up before it is sent', async () => { + let resolveFirst!: () => void; + mockChat.mockImplementation( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' })); + const first = chat.sendMessage('first'); + await new Promise((r) => setTimeout(r, 0)); + + await chat.sendMessage('second'); + + const queued = chat.queuedFollowUps()[0]; + chat.cancelQueuedFollowUp(queued.id); + + expect(chat.queuedFollowUpCount()).toBe(0); + expect(chat.messages().some((message) => message.content === 'second')).toBe(false); + + resolveFirst(); + await first; + await new Promise((r) => setTimeout(r, 0)); + + expect(mockChat).toHaveBeenCalledTimes(1); + dispose(); + }); + it('stop aborts the browser stream and backend session', async () => { let capturedSignal: AbortSignal | undefined; const abortError = new Error('Aborted'); @@ -1192,6 +1225,39 @@ describe('useChat', () => { expect(assistant?.interruption).toBe('stopped'); dispose(); }); + + it('clears queued follow-ups when stopping the active response', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + mockChat.mockImplementation( + ( + _p: string, + _s: string, + _m: string | undefined, + _onEvent: (e: StreamEvent) => void, + signal?: AbortSignal, + ) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(abortError)); + }), + ); + + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + const send = chat.sendMessage('hello'); + await new Promise((r) => setTimeout(r, 0)); + + await chat.sendMessage('queued follow-up'); + expect(chat.queuedFollowUpCount()).toBe(1); + expect(chat.messages().some((message) => message.delivery === 'queued')).toBe(true); + + chat.stop(); + await expect(send).resolves.toBe(false); + + expect(chat.queuedFollowUpCount()).toBe(0); + expect(chat.messages().some((message) => message.delivery === 'queued')).toBe(false); + expect(mockChat).toHaveBeenCalledTimes(1); + dispose(); + }); }); // ────────────────────────────────────────────── @@ -1211,6 +1277,23 @@ describe('useChat', () => { expect(chat.sessionId()).toBe(''); dispose(); }); + + it('clears queued follow-ups', async () => { + mockChat.mockImplementation(() => new Promise(() => {})); + + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess-1' })); + void chat.sendMessage('active'); + await new Promise((r) => setTimeout(r, 0)); + await chat.sendMessage('queued'); + + expect(chat.queuedFollowUpCount()).toBe(1); + + chat.clearMessages(); + + expect(chat.queuedFollowUpCount()).toBe(0); + expect(chat.messages()).toEqual([]); + dispose(); + }); }); // ────────────────────────────────────────────── @@ -1243,6 +1326,25 @@ describe('useChat', () => { dispose(); }); + it('clears queued follow-ups before loading another session', async () => { + mockChat.mockImplementation(() => new Promise(() => {})); + mockGetMessages.mockResolvedValue([]); + + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess-1' })); + void chat.sendMessage('active'); + await new Promise((r) => setTimeout(r, 0)); + await chat.sendMessage('queued'); + + expect(chat.queuedFollowUpCount()).toBe(1); + + const loaded = await chat.loadSession('sess-2'); + + expect(loaded).toBe(true); + expect(chat.queuedFollowUpCount()).toBe(0); + expect(chat.messages()).toEqual([]); + dispose(); + }); + it('handles load error gracefully', async () => { mockGetMessages.mockRejectedValue(new Error('not found')); @@ -1278,6 +1380,24 @@ describe('useChat', () => { expect(onConversationChanged).toHaveBeenCalledTimes(1); dispose(); }); + + it('clears queued follow-ups when starting a blank conversation', async () => { + mockChat.mockImplementation(() => new Promise(() => {})); + + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'old' })); + void chat.sendMessage('active'); + await new Promise((r) => setTimeout(r, 0)); + await chat.sendMessage('queued'); + + expect(chat.queuedFollowUpCount()).toBe(1); + + await chat.newSession(); + + expect(chat.queuedFollowUpCount()).toBe(0); + expect(chat.sessionId()).toBe(''); + expect(chat.messages()).toEqual([]); + dispose(); + }); }); // ────────────────────────────────────────────── diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index b7c417795..5e22cb551 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -38,12 +38,23 @@ export interface SendMessageOptions { handoffMetadata?: ChatHandoffMetadata; } +export interface QueuedFollowUp { + id: string; + messageId: string; + prompt: string; + mentions?: ChatMention[]; + findingId?: string; + sendOptions?: SendMessageOptions; + timestamp: Date; +} + export function useChat(options: UseChatOptions = {}) { // Core state const [messages, setMessages] = createSignal([]); const [isLoading, setIsLoading] = createSignal(false); const [sessionId, setSessionId] = createSignal(options.sessionId || ''); const [model, setModel] = createSignal(options.model || ''); + const [queuedFollowUps, setQueuedFollowUps] = createSignal([]); const notifyConversationChanged = async () => { if (!options.onConversationChanged) return; @@ -58,6 +69,7 @@ export function useChat(options: UseChatOptions = {}) { let abortControllerRef: AbortController | null = null; let activeRequestId = 0; let pendingBackendAbort: Promise | null = null; + let isDrainingQueuedFollowUps = false; const suppressedRawContentMessageIds = new Set(); const abortBackendSession = (targetSessionId: string): Promise | null => { @@ -109,6 +121,28 @@ export function useChat(options: UseChatOptions = {}) { return abortBackendSession(targetSessionId); }; + const removeQueuedMessages = (messageIds: Set) => { + if (messageIds.size === 0) return; + setMessages((prev) => + prev.filter( + (msg) => !(msg.role === 'user' && msg.delivery === 'queued' && messageIds.has(msg.id)), + ), + ); + }; + + const cancelQueuedFollowUp = (id: string) => { + const item = queuedFollowUps().find((entry) => entry.id === id); + if (!item) return; + setQueuedFollowUps((prev) => prev.filter((entry) => entry.id !== id)); + removeQueuedMessages(new Set([item.messageId])); + }; + + const clearQueuedFollowUps = () => { + const messageIds = new Set(queuedFollowUps().map((entry) => entry.messageId)); + setQueuedFollowUps([]); + removeQueuedMessages(messageIds); + }; + // Cleanup on unmount onCleanup(() => { void cancelActiveRequest(); @@ -116,6 +150,7 @@ export function useChat(options: UseChatOptions = {}) { // Stop/cancel current request const stop = () => { + clearQueuedFollowUps(); void cancelActiveRequest('stopped'); }; @@ -571,34 +606,65 @@ export function useChat(options: UseChatOptions = {}) { ); }; - // Send a message - allows sending mid-stream (aborts current response like Pulse AI TUI) - const sendMessage = async ( + const queueFollowUp = ( prompt: string, mentions?: ChatMention[], findingId?: string, sendOptions?: SendMessageOptions, + ) => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) return false; + + const id = generateId(); + const messageId = generateId(); + const timestamp = new Date(); + + const queuedUserMessage: ChatMessage = { + id: messageId, + role: 'user', + content: trimmedPrompt, + timestamp, + delivery: 'queued', + }; + + const queuedFollowUp: QueuedFollowUp = { + id, + messageId, + prompt: trimmedPrompt, + mentions, + findingId, + sendOptions, + timestamp, + }; + + setMessages((prev) => [...prev, queuedUserMessage]); + setQueuedFollowUps((prev) => [...prev, queuedFollowUp]); + logger.debug('[useChat] Queued follow-up while assistant response is streaming', { + queuedFollowUpId: id, + }); + return true; + }; + + const startMessageSend = async ( + prompt: string, + mentions?: ChatMention[], + findingId?: string, + sendOptions?: SendMessageOptions, + options?: { + queuedMessageId?: string; + drainAfter?: boolean; + }, ): Promise => { - if (!prompt.trim()) return false; - - let backendAbortBeforeNextSend: Promise | null = null; - const abortedSessionId = sessionId(); - - // If already streaming, abort the current request first. - // The UI is updated immediately; the next backend stream waits only when it - // would reuse the same session, so the abort endpoint cannot cancel the new - // run after it registers. - if (isLoading()) { - logger.debug('[useChat] Aborting current stream to send new message'); - backendAbortBeforeNextSend = cancelActiveRequest('replaced'); - } + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) return false; // Echo the user's message before any network work. Cold sessions can spend // noticeable time creating the server-side session; the chat surface should // still feel immediate. const userMessage: ChatMessage = { - id: generateId(), + id: options?.queuedMessageId || generateId(), role: 'user', - content: prompt, + content: trimmedPrompt, timestamp: new Date(), }; @@ -614,28 +680,30 @@ export function useChat(options: UseChatOptions = {}) { streamEvents: [], }; - setMessages((prev) => [...prev, userMessage, streamingMessage]); + if (options?.queuedMessageId) { + setMessages((prev) => [ + ...prev.map((msg) => + msg.id === options.queuedMessageId ? { ...msg, delivery: 'sent' as const } : msg, + ), + streamingMessage, + ]); + } else { + setMessages((prev) => [...prev, userMessage, streamingMessage]); + } setIsLoading(true); const requestId = ++activeRequestId; // Existing sessions preserve conversation continuity. Cold chats let the // backend create the session inside the stream and bind via the session SSE // event, avoiding a separate preflight request before first token. - let currentSessionId = sessionId(); - - if (backendAbortBeforeNextSend && abortedSessionId && currentSessionId === abortedSessionId) { - await backendAbortBeforeNextSend; - if (requestId !== activeRequestId) { - return false; - } - } + const currentSessionId = sessionId(); const abortController = new AbortController(); abortControllerRef = abortController; try { await AIChatAPI.chat( - prompt, + trimmedPrompt, currentSessionId || undefined, model() || undefined, (event: StreamEvent) => { @@ -679,13 +747,61 @@ export function useChat(options: UseChatOptions = {}) { if (requestId === activeRequestId) { abortControllerRef = null; setIsLoading(false); + if (options?.drainAfter !== false) { + queueMicrotask(() => { + void drainQueuedFollowUps(); + }); + } } } }; + async function drainQueuedFollowUps() { + if (isDrainingQueuedFollowUps || isLoading()) return; + + isDrainingQueuedFollowUps = true; + try { + while (!isLoading()) { + const next = queuedFollowUps()[0]; + if (!next) return; + + setQueuedFollowUps((prev) => prev.filter((entry) => entry.id !== next.id)); + await startMessageSend(next.prompt, next.mentions, next.findingId, next.sendOptions, { + queuedMessageId: next.messageId, + drainAfter: false, + }); + } + } finally { + isDrainingQueuedFollowUps = false; + } + + if (queuedFollowUps().length > 0 && !isLoading()) { + queueMicrotask(() => { + void drainQueuedFollowUps(); + }); + } + } + + // Send a message. While an assistant response is active, accept the user's + // follow-up immediately and queue it behind the current turn instead of + // replacing or aborting the stream. + const sendMessage = async ( + prompt: string, + mentions?: ChatMention[], + findingId?: string, + sendOptions?: SendMessageOptions, + ): Promise => { + if (!prompt.trim()) return false; + if (isLoading()) { + return queueFollowUp(prompt, mentions, findingId, sendOptions); + } + return startMessageSend(prompt, mentions, findingId, sendOptions); + }; + // Clear messages and reset session (for starting fresh) const clearMessages = () => { void cancelActiveRequest(); + setQueuedFollowUps([]); setMessages([]); setSessionId(''); // Clear session so next message creates a new one }; @@ -693,6 +809,7 @@ export function useChat(options: UseChatOptions = {}) { // Load session messages const loadSession = async (id: string): Promise => { void cancelActiveRequest(); + setQueuedFollowUps([]); try { const msgs = await AIChatAPI.getMessages(id); setMessages( @@ -717,6 +834,7 @@ export function useChat(options: UseChatOptions = {}) { // next chat stream so the UI does not create empty server-side sessions. const newSession = async (): Promise => { void cancelActiveRequest(); + setQueuedFollowUps([]); setSessionId(''); setMessages([]); return true; @@ -869,14 +987,14 @@ export function useChat(options: UseChatOptions = {}) { // Useful for sending follow-up messages after approvals const waitForIdle = (timeoutMs = 30000): Promise => { return new Promise((resolve) => { - if (!isLoading()) { + if (!isLoading() && queuedFollowUps().length === 0 && !isDrainingQueuedFollowUps) { resolve(true); return; } const startTime = Date.now(); const checkInterval = setInterval(() => { - if (!isLoading()) { + if (!isLoading() && queuedFollowUps().length === 0 && !isDrainingQueuedFollowUps) { clearInterval(checkInterval); resolve(true); } else if (Date.now() - startTime > timeoutMs) { @@ -911,9 +1029,13 @@ export function useChat(options: UseChatOptions = {}) { sessionId, model, setModel, + queuedFollowUps, + queuedFollowUpCount: () => queuedFollowUps().length, sendMessage, retryMessage, stop, + cancelQueuedFollowUp, + clearQueuedFollowUps, clearMessages, loadSession, newSession, diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 75672ff38..47e8f8f63 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -11,6 +11,7 @@ import { import { unwrap } from 'solid-js/store'; import SendIcon from 'lucide-solid/icons/send'; import SquareIcon from 'lucide-solid/icons/square'; +import ClockIcon from 'lucide-solid/icons/clock'; import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; import SettingsIcon from 'lucide-solid/icons/settings'; import XIcon from 'lucide-solid/icons/x'; @@ -157,14 +158,17 @@ const findProviderReadinessAlternative = (args: { return normalizeComparableModelKey(model.id) === selectedKey; }) .sort((left, right) => { - const leftProviderOrder = configuredProviderOrder.get(left.provider) ?? Number.MAX_SAFE_INTEGER; + const leftProviderOrder = + configuredProviderOrder.get(left.provider) ?? Number.MAX_SAFE_INTEGER; const rightProviderOrder = configuredProviderOrder.get(right.provider) ?? Number.MAX_SAFE_INTEGER; if (leftProviderOrder !== rightProviderOrder) return leftProviderOrder - rightProviderOrder; if (Boolean(left.model.notable) !== Boolean(right.model.notable)) { return right.model.notable ? 1 : -1; } - return formatAIModelRouteLabel(left.model).localeCompare(formatAIModelRouteLabel(right.model)); + return formatAIModelRouteLabel(left.model).localeCompare( + formatAIModelRouteLabel(right.model), + ); }); const candidate = candidates[0]; @@ -798,7 +802,11 @@ export const AIChat: Component = (props) => { if (!chat.isLoading()) return null; const messages = chat.messages(); - const lastMessage = messages[messages.length - 1]; + const lastMessage = + [...messages] + .reverse() + .find((message) => message.role === 'assistant' && message.isStreaming) || + messages[messages.length - 1]; if (!lastMessage || lastMessage.role !== 'assistant') { return { type: 'thinking', text: 'Thinking...' }; @@ -1772,9 +1780,7 @@ export const AIChat: Component = (props) => {
{presentation().title}
{presentation().body}
- {(recommendation) => ( -
{recommendation()}
- )} + {(recommendation) =>
{recommendation()}
}
@@ -1991,6 +1997,30 @@ export const AIChat: Component = (props) => { {/* Input */}
+ 0}> +
+
+
{ e.preventDefault(); @@ -2043,8 +2073,8 @@ export const AIChat: Component = (props) => { type="submit" disabled={!input().trim() || providerReadinessHasBlockingError()} class="flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-45" - title="Send" - aria-label="Send message" + title={chat.isLoading() ? 'Queue follow-up' : 'Send'} + aria-label={chat.isLoading() ? 'Queue follow-up' : 'Send message'} > diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 4e3451bd1..185c10559 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -113,6 +113,7 @@ export interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; + delivery?: 'sent' | 'queued'; interruption?: 'stopped' | 'replaced'; // Clean, user-facing error for a failed turn. Rendered as a distinct error // block (not as answer content) so partial streamed content is preserved and