mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
Add Assistant queued follow-up dock
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user