diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4cbaa32ee..4e662a224 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 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 d029f977b..39f35b0db 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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 ──────────────────────────────────────────────────── 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 dcbc784f4..ccd99acca 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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((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'); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 5e22cb551..6689c357a 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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, diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 47e8f8f63..a0dacc95a 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -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 = (props) => { const isOpen = aiChatStore.isOpenSignal; const { width } = useBreakpoint(); const [input, setInput] = createSignal(''); + const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal( + null, + ); const [sessions, setSessions] = createSignal([]); const [showSessions, setShowSessions] = createSignal(false); const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 }); @@ -496,6 +500,36 @@ export const AIChat: Component = (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 = (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 = (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 = (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 = (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 = (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 = (props) => {
0}>
-