Promote queued Assistant follow-ups

This commit is contained in:
rcourtman
2026-06-07 07:49:22 +01:00
parent c5a4b6591a
commit c12e044285
6 changed files with 238 additions and 7 deletions
@@ -476,6 +476,36 @@ describe('AIChatAPI', () => {
});
});
it('runs the queue-hold dev stream fixture without opening a provider request', async () => {
const onEvent = vi.fn();
await AIChatAPI.chat('/fixture queue-hold', undefined, 'openrouter:qwen/qwen3.7-plus', onEvent);
expect(apiFetchMock).not.toHaveBeenCalled();
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
'session',
'workflow_state',
'workflow_state',
'workflow_state',
'content',
'done',
]);
expect(onEvent.mock.calls[3][0]).toMatchObject({
type: 'workflow_state',
data: {
phase: 'stream_idle',
message: 'Holding the Assistant turn open so queued follow-ups can be reordered.',
},
});
expect(onEvent.mock.calls[5][0]).toMatchObject({
type: 'done',
data: {
session_id: 'dev-fixture-queue-hold',
model: 'openrouter:qwen/qwen3.7-plus',
},
});
});
it('runs the compacted-artifact dev stream fixture without opening a provider request', async () => {
const onEvent = vi.fn();
@@ -8,6 +8,7 @@ export const AI_CHAT_DEV_STREAM_FIXTURE_PROMPTS = [
'/fixture pending-tool',
'/fixture provider-retry',
'/fixture stream-idle',
'/fixture queue-hold',
'/fixture compacted-artifact',
'/fixture skipped-tool',
] as const;
@@ -537,6 +538,51 @@ const buildStreamIdleFixtureEvents = (model?: string): AIChatStreamEvent[] => [
},
];
const buildQueueHoldFixtureEvents = (model?: string): AIChatStreamEvent[] => [
{
type: 'session',
data: { id: 'dev-fixture-queue-hold' },
},
{
type: 'workflow_state',
data: {
phase: 'request_start',
message: 'Preparing Pulse context.',
},
},
{
type: 'workflow_state',
data: {
phase: 'provider_start',
message: 'Sent request to OpenRouter; waiting for the first token.',
provider: 'openrouter',
model: assistantFixtureModel(model),
},
},
{
type: 'workflow_state',
data: {
phase: 'stream_idle',
message: 'Holding the Assistant turn open so queued follow-ups can be reordered.',
},
},
{
type: 'content',
data: {
text: 'The queue-hold fixture kept the turn active long enough to inspect queued follow-up controls.',
},
},
{
type: 'done',
data: {
session_id: 'dev-fixture-queue-hold',
model: assistantFixtureModel(model),
input_tokens: 44,
output_tokens: 23,
},
},
];
const buildCompactedArtifactFixtureEvents = (model?: string): AIChatStreamEvent[] => [
{
type: 'session',
@@ -619,6 +665,9 @@ const buildFixtureEvents = (prompt: string, model?: string): AIChatStreamEvent[]
if (normalized === '/fixture stream-idle') {
return buildStreamIdleFixtureEvents(model);
}
if (normalized === '/fixture queue-hold') {
return buildQueueHoldFixtureEvents(model);
}
return buildDeviceCountFixtureEvents(model);
};
@@ -651,6 +700,13 @@ const fixtureStepDelay = (
) {
return 1800;
}
if (
normalizedPrompt === '/fixture queue-hold' &&
event.type === 'workflow_state' &&
event.data.phase === 'stream_idle'
) {
return 10000;
}
if (normalizedPrompt === '/fixture pending-tool') return defaultDelayMs;
if (normalizedPrompt !== '/fixture tool-burst') return defaultDelayMs;
if (event.type === 'tool_start') return 0;
@@ -51,6 +51,7 @@ const {
stop: vi.fn(),
cancelQueuedFollowUp: vi.fn(),
takeQueuedFollowUp: vi.fn((): QueuedFollowUp | undefined => undefined),
sendQueuedFollowUpNow: vi.fn().mockResolvedValue(true),
clearQueuedFollowUps: vi.fn(),
clearMessages: vi.fn(),
loadSession: vi.fn().mockResolvedValue(true),
@@ -427,6 +428,7 @@ beforeEach(() => {
mockChat.queuedFollowUps.mockReturnValue([]);
mockChat.queuedFollowUpCount.mockReturnValue(0);
mockChat.sendMessage.mockResolvedValue(true);
mockChat.sendQueuedFollowUpNow.mockResolvedValue(true);
mockChat.undoLastTurn.mockResolvedValue(null);
mockChat.redoLastTurn.mockResolvedValue({ success: false, canRedo: false });
mockChat.takeQueuedFollowUp.mockReturnValue(undefined);
@@ -2388,6 +2390,40 @@ describe('AIChat', () => {
expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1);
});
it('lets a later queued follow-up be promoted to send next', async () => {
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.queryByRole('button', {
name: 'Send queued follow-up next: first queued prompt',
}),
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', {
name: 'Send queued follow-up next: second queued prompt',
}),
);
expect(mockChat.sendQueuedFollowUpNow).toHaveBeenCalledWith('queued-2');
await waitFor(() => expect(document.activeElement).toHaveAttribute('placeholder'));
});
it('passes queued follow-up metadata and row actions into the transcript', () => {
const queuedFollowUps: QueuedFollowUp[] = [
{
@@ -104,6 +104,7 @@ describe('useChat', () => {
expect(typeof chat.stop).toBe('function');
expect(typeof chat.cancelQueuedFollowUp).toBe('function');
expect(typeof chat.takeQueuedFollowUp).toBe('function');
expect(typeof chat.sendQueuedFollowUpNow).toBe('function');
expect(typeof chat.clearQueuedFollowUps).toBe('function');
expect(typeof chat.clearMessages).toBe('function');
expect(typeof chat.loadSession).toBe('function');
@@ -749,6 +750,54 @@ describe('useChat', () => {
dispose();
});
it('promotes a queued follow-up to send next while the active response is streaming', 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');
const third = chat.queuedFollowUps().find((entry) => entry.prompt === 'third');
expect(third).toBeDefined();
await expect(chat.sendQueuedFollowUpNow(third!.id)).resolves.toBe(true);
expect(chat.queuedFollowUps().map((entry) => entry.prompt)).toEqual(['third', 'second']);
expect(
chat
.messages()
.filter((message) => message.role === 'user' && message.delivery === 'queued')
.map((message) => message.content),
).toEqual(['third', 'second']);
resolvers[0]();
await first;
await new Promise((r) => setTimeout(r, 0));
expect(mockChat).toHaveBeenCalledTimes(2);
expect(mockChat.mock.calls[1][0]).toBe('third');
expect(chat.queuedFollowUps().map((entry) => entry.prompt)).toEqual(['second']);
resolvers[1]();
await new Promise((r) => setTimeout(r, 0));
expect(mockChat).toHaveBeenCalledTimes(3);
expect(mockChat.mock.calls[2][0]).toBe('second');
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(
@@ -275,6 +275,40 @@ export function useChat(options: UseChatOptions = {}) {
return item;
};
const moveQueuedMessageToFront = (messageId: string) => {
setMessages((prev) => {
const targetIndex = prev.findIndex(
(msg) => msg.id === messageId && msg.role === 'user' && msg.delivery === 'queued',
);
if (targetIndex < 0) return prev;
const target = prev[targetIndex];
const withoutTarget = prev.filter((msg) => msg.id !== messageId);
const firstQueuedIndex = withoutTarget.findIndex(
(msg) => msg.role === 'user' && msg.delivery === 'queued',
);
if (firstQueuedIndex < 0) return prev;
return [
...withoutTarget.slice(0, firstQueuedIndex),
target,
...withoutTarget.slice(firstQueuedIndex),
];
});
};
const promoteQueuedFollowUp = (id: string): boolean => {
const item = queuedFollowUps().find((entry) => entry.id === id);
if (!item) return false;
setQueuedFollowUps((prev) => {
const currentIndex = prev.findIndex((entry) => entry.id === id);
if (currentIndex <= 0) return prev;
return [prev[currentIndex], ...prev.slice(0, currentIndex), ...prev.slice(currentIndex + 1)];
});
moveQueuedMessageToFront(item.messageId);
return true;
};
const clearQueuedFollowUps = () => {
const messageIds = new Set(queuedFollowUps().map((entry) => entry.messageId));
setQueuedFollowUps([]);
@@ -730,12 +764,7 @@ export function useChat(options: UseChatOptions = {}) {
return {
...msg,
streamEvents: resolvedTool
? replacePendingToolStreamEvents(
msg.streamEvents || [],
resolvedTool,
matchesTool,
now,
)
? replacePendingToolStreamEvents(msg.streamEvents || [], resolvedTool, matchesTool, now)
: msg.streamEvents,
workflowStatus: undefined,
workflowStatusHistory: undefined,
@@ -1861,6 +1890,19 @@ export function useChat(options: UseChatOptions = {}) {
return startMessageSend(prompt, mentions, findingId, sendOptions);
};
const sendQueuedFollowUpNow = async (id: string): Promise<boolean> => {
const item = queuedFollowUps().find((entry) => entry.id === id);
if (!item) return false;
if (isLoading()) {
return promoteQueuedFollowUp(id);
}
setQueuedFollowUps((prev) => prev.filter((entry) => entry.id !== id));
return startMessageSend(item.prompt, item.mentions, item.findingId, item.sendOptions, {
queuedMessageId: item.messageId,
});
};
// Clear messages and reset session (for starting fresh)
const clearMessages = () => {
void cancelActiveRequest();
@@ -2179,6 +2221,7 @@ export function useChat(options: UseChatOptions = {}) {
queuedFollowUps,
queuedFollowUpCount: () => queuedFollowUps().length,
sendMessage,
sendQueuedFollowUpNow,
retryMessage,
undoLastTurn,
redoLastTurn,
@@ -1585,6 +1585,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
queueMicrotask(resizeTextarea);
};
const sendQueuedFollowUpNext = (id: string) => {
void chat.sendQueuedFollowUpNow(id).finally(() => {
focusComposer();
});
};
const restoreLastTurnDraft = (draft: RestoredPromptDraft) => {
resetPromptHistoryNavigation();
setEditingQueuedFollowUp(null);
@@ -4349,11 +4355,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
<div class="mt-1 max-h-24 space-y-1 overflow-y-auto">
<For each={chat.queuedFollowUps()}>
{(queued) => {
{(queued, index) => {
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>
<Show when={chat.queuedFollowUpCount() > 1 && index() > 0}>
<button
type="button"
onClick={() => sendQueuedFollowUpNext(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="Send queued follow-up next"
aria-label={`Send queued follow-up next: ${preview()}`}
>
<SendIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</Show>
<button
type="button"
onClick={() => editQueuedFollowUp(queued.id)}