diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index f949d0ecff..5051861295 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -589,13 +589,31 @@ export const AIChat: Component = (props) => { } }; - // Pre-fill input when opened with an initialPrompt (e.g., "Discuss with Assistant" from findings) + // Pre-fill input when opened with an initialPrompt (e.g., "Discuss with + // Assistant" from findings). When autoSendInitialPrompt is true, + // immediately submit instead of waiting for the operator to press Enter + // — used by action-style entry points like "Explain" where clicking the + // button is itself the operator's decision to invoke the action. createEffect(() => { const ctx = aiChatStore.context; if (ctx.initialPrompt && isOpen()) { - setInput(ctx.initialPrompt); - // Clear so it doesn't re-fire on subsequent opens + const promptToSend = ctx.initialPrompt; + const shouldAutoSend = Boolean(ctx.autoSendInitialPrompt); + setInput(promptToSend); + // Clear flags so they don't re-fire on subsequent opens aiChatStore.clearInitialPrompt?.(); + aiChatStore.clearAutoSendFlag?.(); + if (shouldAutoSend) { + // Defer to the next tick so the input signal has propagated to + // handleSubmit's `input()` read, and so the drawer mount / + // session-load effects can settle. handleSubmit itself guards + // against empty prompts and against double-firing while a chat + // is in flight, so this is safe. + queueMicrotask(() => { + if (!isOpen()) return; + handleSubmit(); + }); + } } }); diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 68d1ad9770..574a5779bf 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -559,7 +559,16 @@ export const FindingsPanel: Component = (props) => { nextStepAction, intent, }); - aiChatStore.openWithPrompt(handoff.prompt, handoff.context); + // Explain is an action-style entry point — clicking the button is the + // operator asking "do the explaining for me," not "open chat so I can + // type a question." Auto-submit the handoff prompt so the analysis is + // already in flight when the drawer is on screen. Discuss with + // Assistant keeps the previous behaviour (pre-fill, operator drives) + // because that entry is open-ended by intent. + aiChatStore.openWithPrompt(handoff.prompt, { + ...handoff.context, + autoSendInitialPrompt: intent === 'explain', + }); }; const handleDiscussWithAssistant = async (finding: UnifiedFinding, e: Event) => { diff --git a/frontend-modern/src/stores/__tests__/aiChat.test.ts b/frontend-modern/src/stores/__tests__/aiChat.test.ts index 69082c6655..744af8d2ba 100644 --- a/frontend-modern/src/stores/__tests__/aiChat.test.ts +++ b/frontend-modern/src/stores/__tests__/aiChat.test.ts @@ -135,6 +135,37 @@ describe('aiChatStore', () => { }); }); + it('threads autoSendInitialPrompt through openWithPrompt and clears it on clearAutoSendFlag', () => { + // Action-style entry points (Explain, Investigate, eventually Verify + // fix) set autoSendInitialPrompt: true so the chat surface submits + // immediately on open instead of pre-filling the input and waiting + // for Enter. Discuss-style entries leave it falsy. + aiChatStore.openWithPrompt('Explain this Patrol finding', { + targetType: 'vm', + targetId: 'vm-100', + autoSendInitialPrompt: true, + }); + + expect(aiChatStore.context.initialPrompt).toBe('Explain this Patrol finding'); + expect(aiChatStore.context.autoSendInitialPrompt).toBe(true); + + aiChatStore.clearAutoSendFlag(); + + expect(aiChatStore.context.autoSendInitialPrompt).toBeUndefined(); + // The initialPrompt is independent — clearAutoSendFlag must not also + // clear the prompt itself (the chat effect still needs to read and + // submit it before its own clearInitialPrompt runs). + expect(aiChatStore.context.initialPrompt).toBe('Explain this Patrol finding'); + }); + + it('defaults autoSendInitialPrompt to undefined when not provided', () => { + aiChatStore.openWithPrompt('discuss this', { + targetType: 'vm', + targetId: 'vm-200', + }); + expect(aiChatStore.context.autoSendInitialPrompt).toBeUndefined(); + }); + it('preserves scoped autonomous-mode overrides for pre-filled prompts', () => { aiChatStore.openWithPrompt('brief me', { targetType: 'dashboard', diff --git a/frontend-modern/src/stores/aiChat.ts b/frontend-modern/src/stores/aiChat.ts index 21084f4e03..beff3f04aa 100644 --- a/frontend-modern/src/stores/aiChat.ts +++ b/frontend-modern/src/stores/aiChat.ts @@ -83,6 +83,15 @@ export interface AIChatContext { handoffMetadata?: AIChatHandoffMetadata; // Per-request execution mode override; false keeps scoped handoffs approval-required. autonomousMode?: boolean; + // When true, the chat surface should auto-submit the initialPrompt as + // soon as the drawer opens, rather than just pre-filling the input and + // waiting for the operator to press Enter. Used by action-style entry + // points like "Explain" / "Investigate" where the operator has already + // decided to invoke the action by clicking the button — leaving them + // to also press Enter is friction. Discuss-style entry points where + // the operator is opening chat to drive the conversation themselves + // should leave this false (the default). + autoSendInitialPrompt?: boolean; } // A single context item that can be accumulated @@ -484,6 +493,16 @@ export const aiChatStore = { }); }, + // Clear the auto-send flag after firing, so a subsequent open of the + // drawer without an explicit autoSend doesn't accidentally re-fire. + clearAutoSendFlag() { + setAIChatContext((prev) => { + if (!prev.autoSendInitialPrompt) return prev; + const { autoSendInitialPrompt: _, ...rest } = prev; + return rest; + }); + }, + // Clear the findingId after first message is sent clearFindingId() { setAIChatContext((prev) => {