Queue Assistant follow-up sends

This commit is contained in:
rcourtman
2026-06-05 14:23:24 +01:00
parent 05d00a65a6
commit af103d8b09
8 changed files with 467 additions and 113 deletions
@@ -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
@@ -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<MessageItemProps> = (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<MessageItemProps> = (props) => {
<div class={`${isUser() ? 'flex justify-end' : ''} mb-4`}>
{/* User message - compact bubble */}
<Show when={isUser()}>
<div class="max-w-[85%] px-4 py-2.5 rounded-md rounded-br-sm bg-blue-600 text-white shadow-sm">
<div
class={`max-w-[85%] px-4 py-2.5 rounded-md rounded-br-sm shadow-sm ${
isQueuedUserMessage()
? 'border border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-100'
: 'bg-blue-600 text-white'
}`}
>
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
<Show when={isQueuedUserMessage()}>
<div
class="mt-1.5 flex items-center justify-end gap-1 text-[11px] font-medium text-blue-700 dark:text-blue-300"
role="status"
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
<span>Queued</span>
</div>
</Show>
</div>
</Show>
@@ -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();
@@ -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(() => (
<MessageItem
message={makeMessage({
role: 'user',
content: 'follow up after this',
delivery: 'queued',
})}
{...makeHandlers()}
/>
));
expect(screen.getByText('follow up after this')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Queued');
});
});
describe('error block', () => {
@@ -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<void>((_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<void>((_resolve, reject) => {
signal?.addEventListener('abort', () => {
queueMicrotask(() => reject(abortError));
});
return new Promise<void>((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<void>((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<void>((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<void>((_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();
});
});
// ──────────────────────────────────────────────
@@ -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<ChatMessage[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [sessionId, setSessionId] = createSignal(options.sessionId || '');
const [model, setModel] = createSignal(options.model || '');
const [queuedFollowUps, setQueuedFollowUps] = createSignal<QueuedFollowUp[]>([]);
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<void> | null = null;
let isDrainingQueuedFollowUps = false;
const suppressedRawContentMessageIds = new Set<string>();
const abortBackendSession = (targetSessionId: string): Promise<void> | null => {
@@ -109,6 +121,28 @@ export function useChat(options: UseChatOptions = {}) {
return abortBackendSession(targetSessionId);
};
const removeQueuedMessages = (messageIds: Set<string>) => {
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<boolean> => {
if (!prompt.trim()) return false;
let backendAbortBeforeNextSend: Promise<void> | 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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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,
@@ -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<AIChatProps> = (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<AIChatProps> = (props) => {
<div class="font-semibold text-base-content">{presentation().title}</div>
<div class="mt-0.5 leading-5">{presentation().body}</div>
<Show when={presentation().recommendation}>
{(recommendation) => (
<div class="mt-0.5 leading-5">{recommendation()}</div>
)}
{(recommendation) => <div class="mt-0.5 leading-5">{recommendation()}</div>}
</Show>
</div>
</div>
@@ -1991,6 +1997,30 @@ export const AIChat: Component<AIChatProps> = (props) => {
{/* Input */}
<div class="border-t border-border bg-surface px-4 py-3">
<Show when={chat.queuedFollowUpCount() > 0}>
<div
class="mb-2 flex min-h-8 items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-blue-800 shadow-sm dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
role="status"
aria-label="Queued follow-up messages"
>
<ClockIcon class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate text-xs font-medium">
{pluralizeCount(chat.queuedFollowUpCount(), 'follow-up', 'follow-ups')} queued
</span>
<button
type="button"
onClick={() => {
chat.clearQueuedFollowUps();
focusComposer();
}}
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-900 dark:text-blue-200 dark:hover:bg-blue-900/50"
title="Clear queued follow-ups"
aria-label="Clear queued follow-up messages"
>
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</Show>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -2043,8 +2073,8 @@ export const AIChat: Component<AIChatProps> = (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'}
>
<SendIcon class="h-4 w-4" />
</button>
@@ -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