Auto-send on action-style Assistant entry points

Pulse Assistant has been reactive: clicking Explain on a Patrol
finding opened the drawer, pre-filled a substantive investigation
prompt, and then waited for the operator to press Enter. The
captured Pulse Intelligence direction calls for the opposite —
when the operator clicks an action button, the analysis should
already be in flight by the time they land on the drawer, not
parked on a textarea waiting for a second confirmation step.

Adds autoSendInitialPrompt to AIChatContext. When true, the chat
surface fires handleSubmit immediately after the initialPrompt is
written to the input (deferred via queueMicrotask so the input
signal has propagated and the drawer-open effects have settled).
handleSubmit's existing guards against empty prompts and concurrent
submissions make this safe. clearAutoSendFlag is the symmetric
clearer so the flag doesn't persist across opens.

Wires Explain to autoSendInitialPrompt: true; Discuss with
Assistant stays false because that entry is open-ended by
design. The same plumbing is what future action-style entries
(Investigate, Verify fix) will route through.

Tests: two new aiChat-store cases lock in the flag plumbing and
the default-undefined behaviour. All 15 aiChat-store + 70
AIChat-component tests pass. Verified live: clicking Explain
clears the textarea and surfaces the in-flight Thinking
indicator without the operator pressing Enter.
This commit is contained in:
rcourtman
2026-05-11 09:09:38 +01:00
parent b388e79c73
commit 8230275126
4 changed files with 81 additions and 4 deletions
@@ -589,13 +589,31 @@ export const AIChat: Component<AIChatProps> = (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();
});
}
}
});
@@ -559,7 +559,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (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) => {
@@ -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',
+19
View File
@@ -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) => {