From c12e044285d75b17630a47047edb4cf7d5e02a3f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Jun 2026 07:49:22 +0100 Subject: [PATCH] Promote queued Assistant follow-ups --- .../src/api/__tests__/aiChat.test.ts | 30 ++++++++++ .../src/api/aiChatDevStreamFixture.ts | 56 +++++++++++++++++++ .../AI/Chat/__tests__/AIChat.test.tsx | 36 ++++++++++++ .../AI/Chat/__tests__/useChat.test.ts | 49 ++++++++++++++++ .../src/components/AI/Chat/hooks/useChat.ts | 55 ++++++++++++++++-- .../src/components/AI/Chat/index.tsx | 19 ++++++- 6 files changed, 238 insertions(+), 7 deletions(-) diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index b0fa0a98f..e67441f2e 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -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(); diff --git a/frontend-modern/src/api/aiChatDevStreamFixture.ts b/frontend-modern/src/api/aiChatDevStreamFixture.ts index 5fcf66ab2..6fde44f23 100644 --- a/frontend-modern/src/api/aiChatDevStreamFixture.ts +++ b/frontend-modern/src/api/aiChatDevStreamFixture.ts @@ -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; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index c8e9f86c9..079dc1eb8 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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[] = [ { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 23ca705a0..6056e1c29 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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((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( diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 36885c90c..89fb90bca 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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 => { + 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, diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 519592df7..e40cb4c43 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1585,6 +1585,12 @@ export const AIChat: Component = (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 = (props) => {
- {(queued) => { + {(queued, index) => { const preview = () => queuedFollowUpPreview(queued.prompt); return (
{preview()} + 1 && index() > 0}> + +