mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
Queue Assistant follow-up sends
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user