Add Assistant queued follow-up dock

This commit is contained in:
rcourtman
2026-06-05 14:33:21 +01:00
parent af103d8b09
commit 45f8757812
5 changed files with 261 additions and 39 deletions
@@ -85,8 +85,9 @@ runtime cost control, and shared AI transport surfaces.
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
show an itemized composer-adjacent queue with per-follow-up edit/remove
controls plus clear-all, 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
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, afterEach, beforeAll, beforeEach } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import type { ChatMessage, ModelInfo } from '../types';
import type { QueuedFollowUp } from '../hooks/useChat';
// ── Hoisted mocks (vi.mock factories reference these) ──────────────────────
@@ -20,11 +21,12 @@ const {
sessionId: vi.fn(() => ''),
model: vi.fn(() => ''),
setModel: vi.fn(),
queuedFollowUps: vi.fn(() => []),
queuedFollowUps: vi.fn((): QueuedFollowUp[] => []),
queuedFollowUpCount: vi.fn(() => 0),
sendMessage: vi.fn().mockResolvedValue(true),
stop: vi.fn(),
cancelQueuedFollowUp: vi.fn(),
takeQueuedFollowUp: vi.fn((): QueuedFollowUp | undefined => undefined),
clearQueuedFollowUps: vi.fn(),
clearMessages: vi.fn(),
loadSession: vi.fn().mockResolvedValue(true),
@@ -318,6 +320,7 @@ beforeEach(() => {
mockChat.queuedFollowUps.mockReturnValue([]);
mockChat.queuedFollowUpCount.mockReturnValue(0);
mockChat.sendMessage.mockResolvedValue(true);
mockChat.takeQueuedFollowUp.mockReturnValue(undefined);
mockByType.mockReturnValue([]);
mockResources.mockReturnValue([]);
mockWebSocketState.resources = [];
@@ -1070,16 +1073,104 @@ describe('AIChat', () => {
it('shows queued follow-up count and clears queued follow-ups', () => {
mockChat.queuedFollowUpCount.mockReturnValue(2);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'first queued prompt',
timestamp: new Date(),
},
{
id: 'queued-2',
messageId: 'msg-queued-2',
prompt: 'second queued prompt',
timestamp: new Date(),
},
]);
renderChat();
expect(screen.getByRole('status', { name: 'Queued follow-up messages' })).toHaveTextContent(
'2 follow-ups queued',
);
expect(screen.getByText('first queued prompt')).toBeInTheDocument();
expect(screen.getByText('second queued prompt')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Clear queued follow-up messages' }));
expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1);
});
it('removes an individual queued follow-up', () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'remove this queued prompt',
timestamp: new Date(),
},
]);
renderChat();
fireEvent.click(
screen.getByRole('button', {
name: 'Remove queued follow-up: remove this queued prompt',
}),
);
expect(mockChat.cancelQueuedFollowUp).toHaveBeenCalledWith('queued-1');
});
it('loads an individual queued follow-up into the composer for editing', () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'edit this queued prompt',
timestamp: new Date(),
},
]);
mockChat.takeQueuedFollowUp.mockReturnValue({
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'edit this queued prompt',
mentions: [{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
findingId: 'finding-1',
sendOptions: {
autonomousMode: false,
handoffContext: 'scoped context',
},
timestamp: new Date(),
});
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.click(
screen.getByRole('button', {
name: 'Edit queued follow-up: edit this queued prompt',
}),
);
expect(mockChat.takeQueuedFollowUp).toHaveBeenCalledWith('queued-1');
expect(textarea.value).toBe('edit this queued prompt');
fireEvent.input(textarea, { target: { value: 'edited queued prompt' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockChat.sendMessage).toHaveBeenCalledWith(
'edited queued prompt',
[{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
'finding-1',
{
autonomousMode: false,
handoffContext: 'scoped context',
},
);
});
});
// ── Control level ────────────────────────────────────────────────────
@@ -85,6 +85,7 @@ describe('useChat', () => {
expect(typeof chat.retryMessage).toBe('function');
expect(typeof chat.stop).toBe('function');
expect(typeof chat.cancelQueuedFollowUp).toBe('function');
expect(typeof chat.takeQueuedFollowUp).toBe('function');
expect(typeof chat.clearQueuedFollowUps).toBe('function');
expect(typeof chat.clearMessages).toBe('function');
expect(typeof chat.loadSession).toBe('function');
@@ -488,6 +489,48 @@ describe('useChat', () => {
dispose();
});
it('takes a queued follow-up for composer editing 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',
[{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
'finding-1',
{ autonomousMode: false, handoffContext: 'scoped context' },
);
const queued = chat.queuedFollowUps()[0];
const taken = chat.takeQueuedFollowUp(queued.id);
expect(taken).toMatchObject({
id: queued.id,
messageId: queued.messageId,
prompt: 'second',
mentions: [{ id: 'vm-1', name: 'web-1', type: 'vm', node: 'pve-1' }],
findingId: 'finding-1',
sendOptions: { autonomousMode: false, handoffContext: 'scoped context' },
});
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');
@@ -137,6 +137,14 @@ export function useChat(options: UseChatOptions = {}) {
removeQueuedMessages(new Set([item.messageId]));
};
const takeQueuedFollowUp = (id: string): QueuedFollowUp | undefined => {
const item = queuedFollowUps().find((entry) => entry.id === id);
if (!item) return undefined;
setQueuedFollowUps((prev) => prev.filter((entry) => entry.id !== id));
removeQueuedMessages(new Set([item.messageId]));
return item;
};
const clearQueuedFollowUps = () => {
const messageIds = new Set(queuedFollowUps().map((entry) => entry.messageId));
setQueuedFollowUps([]);
@@ -1035,6 +1043,7 @@ export function useChat(options: UseChatOptions = {}) {
retryMessage,
stop,
cancelQueuedFollowUp,
takeQueuedFollowUp,
clearQueuedFollowUps,
clearMessages,
loadSession,
+114 -36
View File
@@ -12,6 +12,7 @@ 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 PencilIcon from 'lucide-solid/icons/pencil';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import SettingsIcon from 'lucide-solid/icons/settings';
import XIcon from 'lucide-solid/icons/x';
@@ -71,7 +72,7 @@ import {
getPreferredResourceHostname,
} from '@/utils/resourceIdentity';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useChat, type SendMessageOptions } from './hooks/useChat';
import { useChat, type QueuedFollowUp, type SendMessageOptions } from './hooks/useChat';
import { ChatMessages } from './ChatMessages';
import { ModelSelector } from './ModelSelector';
import { MentionAutocomplete, type MentionResource } from './MentionAutocomplete';
@@ -394,6 +395,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
const isOpen = aiChatStore.isOpenSignal;
const { width } = useBreakpoint();
const [input, setInput] = createSignal('');
const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal<QueuedFollowUp | null>(
null,
);
const [sessions, setSessions] = createSignal<ChatSession[]>([]);
const [showSessions, setShowSessions] = createSignal(false);
const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 });
@@ -496,6 +500,36 @@ export const AIChat: Component<AIChatProps> = (props) => {
onConversationChanged: refreshSessions,
});
const queuedFollowUpPreview = (prompt: string) => {
const firstLine = prompt
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstLine || 'Queued follow-up';
};
const restoreQueuedMentions = (mentions?: QueuedFollowUp['mentions']) => {
setAccumulatedMentions(
(mentions || []).map((mention) => ({
id: mention.id,
label: mention.name,
type: mention.type,
node: mention.node,
})),
);
};
const editQueuedFollowUp = (id: string) => {
const queued = chat.takeQueuedFollowUp(id);
if (!queued) return;
setEditingQueuedFollowUp(queued);
setInput(queued.prompt);
restoreQueuedMentions(queued.mentions);
setMentionActive(false);
focusComposer();
queueMicrotask(resizeTextarea);
};
const defaultModelLabel = createMemo(() => {
const fallback = defaultModel().trim();
if (!fallback) return '';
@@ -1198,22 +1232,27 @@ export const AIChat: Component<AIChatProps> = (props) => {
: undefined;
// Pass findingId from context on the first message, clear after success
const ctx = aiChatStore.context;
const findingId = ctx.findingId;
const sendOptions: SendMessageOptions = {};
if (typeof ctx.autonomousMode === 'boolean') {
sendOptions.autonomousMode = ctx.autonomousMode;
}
if (ctx.handoffContext && ctx.handoffContext.trim()) {
sendOptions.handoffContext = ctx.handoffContext;
}
if (ctx.handoffResources && ctx.handoffResources.length > 0) {
sendOptions.handoffResources = ctx.handoffResources;
}
if (ctx.handoffActions && ctx.handoffActions.length > 0) {
sendOptions.handoffActions = ctx.handoffActions;
}
if (ctx.handoffMetadata) {
sendOptions.handoffMetadata = ctx.handoffMetadata;
const queuedDraft = editingQueuedFollowUp();
const findingId = queuedDraft ? queuedDraft.findingId : ctx.findingId;
const sendOptions: SendMessageOptions = queuedDraft?.sendOptions
? { ...queuedDraft.sendOptions }
: {};
if (!queuedDraft) {
if (typeof ctx.autonomousMode === 'boolean') {
sendOptions.autonomousMode = ctx.autonomousMode;
}
if (ctx.handoffContext && ctx.handoffContext.trim()) {
sendOptions.handoffContext = ctx.handoffContext;
}
if (ctx.handoffResources && ctx.handoffResources.length > 0) {
sendOptions.handoffResources = ctx.handoffResources;
}
if (ctx.handoffActions && ctx.handoffActions.length > 0) {
sendOptions.handoffActions = ctx.handoffActions;
}
if (ctx.handoffMetadata) {
sendOptions.handoffMetadata = ctx.handoffMetadata;
}
}
const hasSendOptions =
typeof sendOptions.autonomousMode === 'boolean' ||
@@ -1231,10 +1270,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
Boolean(ctx.handoffMetadata);
sendPromise.then((ok) => {
if (!ok) return;
if (findingId) {
setEditingQueuedFollowUp(null);
if (!queuedDraft && findingId) {
aiChatStore.clearFindingId?.();
}
if (hasRequestHandoffPayload) {
if (!queuedDraft && hasRequestHandoffPayload) {
aiChatStore.clearRequestHandoffPayload?.();
}
});
@@ -1325,6 +1365,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const handleNewConversation = async () => {
const started = await chat.newSession();
if (!started) return;
setEditingQueuedFollowUp(null);
aiChatStore.clearContext?.();
setShowSessions(false);
focusComposer();
@@ -1361,6 +1402,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const session = sessions().find((candidate) => candidate.id === sessionId);
const loaded = await chat.loadSession(sessionId);
if (!loaded) return;
setEditingQueuedFollowUp(null);
const restoredContext = buildSessionHandoffContext(session);
if (restoredContext) {
aiChatStore.setContext(restoredContext);
@@ -1381,6 +1423,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
updateStoredModel(sessionId, '');
if (chat.sessionId() === sessionId) {
chat.clearMessages();
setEditingQueuedFollowUp(null);
}
} catch (_error) {
notificationStore.error('Failed to delete session');
@@ -1999,26 +2042,61 @@ export const AIChat: Component<AIChatProps> = (props) => {
<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"
class="mb-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 class="flex min-h-7 items-center gap-2">
<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>
<div class="mt-1 max-h-24 space-y-1 overflow-y-auto">
<For each={chat.queuedFollowUps()}>
{(queued) => {
const preview = () => queuedFollowUpPreview(queued.prompt);
return (
<div class="flex min-h-7 items-center gap-2 rounded-md bg-white/70 px-2 py-1 text-xs text-blue-900 dark:bg-blue-900/30 dark:text-blue-100">
<span class="min-w-0 flex-1 truncate">{preview()}</span>
<button
type="button"
onClick={() => editQueuedFollowUp(queued.id)}
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-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
title="Edit queued follow-up"
aria-label={`Edit queued follow-up: ${preview()}`}
>
<PencilIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => {
chat.cancelQueuedFollowUp(queued.id);
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-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
title="Remove queued follow-up"
aria-label={`Remove queued follow-up: ${preview()}`}
>
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
);
}}
</For>
</div>
</div>
</Show>
<form