From 58769c3be957404eb4471ff94bafc683f925a2c1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Jun 2026 14:41:41 +0100 Subject: [PATCH] Add Assistant prompt history recall --- .../v6/internal/subsystems/ai-runtime.md | 6 + .../AI/Chat/__tests__/AIChat.test.tsx | 59 ++++++ .../src/components/AI/Chat/index.tsx | 191 ++++++++++++++++++ 3 files changed, 256 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4e662a224..6f4ac3008 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -93,6 +93,12 @@ runtime cost control, and shared AI transport surfaces. partial model text, return focus to the composer, and render a neutral transcript marker rather than persisting synthetic assistant answer text or surfacing the interruption as a retryable provider failure. + Composer prompt history is also drawer-local chat-runtime state: the drawer + may persist a bounded local history of submitted prompt text and structured + mentions for ArrowUp/ArrowDown recall, but that history must not persist or + replay one-shot finding handoff, approval, autonomous-mode, or other scoped + send options. Scoped context replay remains owned by explicit session + handoff metadata or queued follow-up edit state. Assistant output hygiene is part of the same boundary: provider reasoning and raw serialized tool-call artifacts must never render as assistant transcript prose. Reasoning/thinking deltas may update neutral progress 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 39f35b0db..4540bec82 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -978,6 +978,65 @@ describe('AIChat', () => { expect(mockChat.sendMessage).toHaveBeenCalledWith('hello world', undefined, undefined); }); + it('recalls submitted prompts with ArrowUp and ArrowDown from the empty composer', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText( + 'Ask about your infrastructure...', + ) as HTMLTextAreaElement; + + fireEvent.input(textarea, { target: { value: 'first prompt' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + fireEvent.input(textarea, { target: { value: 'second prompt' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + await waitFor(() => expect(textarea.value).toBe('')); + + fireEvent.keyDown(textarea, { key: 'ArrowUp' }); + await waitFor(() => expect(textarea.value).toBe('second prompt')); + + fireEvent.keyDown(textarea, { key: 'ArrowUp' }); + await waitFor(() => expect(textarea.value).toBe('first prompt')); + + fireEvent.keyDown(textarea, { key: 'ArrowDown' }); + await waitFor(() => expect(textarea.value).toBe('second prompt')); + + fireEvent.keyDown(textarea, { key: 'ArrowDown' }); + await waitFor(() => expect(textarea.value).toBe('')); + }); + + it('does not replace a non-empty composer draft with prompt history', () => { + localStorage.setItem( + 'pulse:ai_chat_prompt_history', + JSON.stringify([{ prompt: 'previous prompt', mentions: [] }]), + ); + + renderChat(); + const textarea = screen.getByPlaceholderText( + 'Ask about your infrastructure...', + ) as HTMLTextAreaElement; + + fireEvent.input(textarea, { target: { value: 'draft prompt' } }); + fireEvent.keyDown(textarea, { key: 'ArrowUp' }); + + expect(textarea.value).toBe('draft prompt'); + }); + + it('loads persisted prompt history for recall', async () => { + localStorage.setItem( + 'pulse:ai_chat_prompt_history', + JSON.stringify([{ prompt: 'persisted prompt', mentions: [] }]), + ); + + renderChat(); + const textarea = screen.getByPlaceholderText( + 'Ask about your infrastructure...', + ) as HTMLTextAreaElement; + + fireEvent.keyDown(textarea, { key: 'ArrowUp' }); + + await waitFor(() => expect(textarea.value).toBe('persisted prompt')); + }); + it('does not send an empty message', () => { renderChat(); const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index a0dacc95a..f960144f0 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -80,8 +80,10 @@ import type { PendingApproval, PendingQuestion } from './types'; import { formatIdentifierLabel } from '@/utils/textPresentation'; const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session'; +const PROMPT_HISTORY_STORAGE_KEY = 'pulse:ai_chat_prompt_history'; const DEFAULT_SESSION_KEY = '__default__'; const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200; +const AI_CHAT_PROMPT_HISTORY_LIMIT = 100; const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']); const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']); @@ -104,6 +106,11 @@ interface ChatProviderReadinessAlternative { providerLabel: string; } +interface PromptHistoryEntry { + prompt: string; + mentions: MentionResource[]; +} + interface AIChatProps { onClose: () => void; } @@ -114,6 +121,45 @@ const compactText = (items: Array): string[] => const pluralizeCount = (count: number, singular: string, plural: string) => `${count} ${count === 1 ? singular : plural}`; +const normalizePromptHistoryEntry = (value: unknown): PromptHistoryEntry | null => { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const prompt = typeof record.prompt === 'string' ? record.prompt : ''; + if (!prompt.trim()) return null; + const mentions = Array.isArray(record.mentions) + ? record.mentions + .map((mention): MentionResource | null => { + if (!mention || typeof mention !== 'object') return null; + const mentionRecord = mention as Record; + const id = typeof mentionRecord.id === 'string' ? mentionRecord.id : ''; + const label = typeof mentionRecord.label === 'string' ? mentionRecord.label : ''; + const type = mentionRecord.type; + if ( + !id || + !label || + !( + type === 'vm' || + type === 'system-container' || + type === 'app-container' || + type === 'agent' || + type === 'storage' + ) + ) { + return null; + } + return { + id, + label, + type, + node: typeof mentionRecord.node === 'string' ? mentionRecord.node : undefined, + status: typeof mentionRecord.status === 'string' ? mentionRecord.status : undefined, + }; + }) + .filter((mention): mention is MentionResource => mention !== null) + : []; + return { prompt, mentions }; +}; + const normalizeComparableModelKey = (modelId: string): string => { const trimmed = modelId.trim().toLowerCase(); if (!trimmed) return ''; @@ -398,6 +444,9 @@ export const AIChat: Component = (props) => { const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal( null, ); + const [promptHistory, setPromptHistory] = createSignal([]); + const [promptHistoryIndex, setPromptHistoryIndex] = createSignal(-1); + const [savedPromptDraft, setSavedPromptDraft] = createSignal(null); const [sessions, setSessions] = createSignal([]); const [showSessions, setShowSessions] = createSignal(false); const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 }); @@ -447,6 +496,129 @@ export const AIChat: Component = (props) => { textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 160)}px`; }; + const cloneMentions = (mentions: MentionResource[]) => + mentions.map((mention) => ({ ...mention })); + + const loadPromptHistory = (): PromptHistoryEntry[] => { + try { + const raw = localStorage.getItem(PROMPT_HISTORY_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + return parsed + .map(normalizePromptHistoryEntry) + .filter((entry): entry is PromptHistoryEntry => entry !== null) + .slice(0, AI_CHAT_PROMPT_HISTORY_LIMIT); + } catch (error) { + logger.warn('[AIChat] Failed to read prompt history:', error); + return []; + } + }; + + const persistPromptHistory = (history: PromptHistoryEntry[]) => { + try { + localStorage.setItem(PROMPT_HISTORY_STORAGE_KEY, JSON.stringify(history)); + } catch (error) { + logger.warn('[AIChat] Failed to persist prompt history:', error); + } + }; + + const promptHistoryEntriesEqual = (a: PromptHistoryEntry, b: PromptHistoryEntry) => { + if (a.prompt.trim() !== b.prompt.trim()) return false; + if (a.mentions.length !== b.mentions.length) return false; + return a.mentions.every((mention, index) => { + const other = b.mentions[index]; + return ( + other && + mention.id === other.id && + mention.label === other.label && + mention.type === other.type && + mention.node === other.node + ); + }); + }; + + const addPromptHistoryEntry = (prompt: string, mentions: MentionResource[]) => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) return; + + const entry: PromptHistoryEntry = { + prompt: trimmedPrompt, + mentions: cloneMentions(mentions), + }; + + setPromptHistory((prev) => { + if (prev[0] && promptHistoryEntriesEqual(prev[0], entry)) return prev; + const next = [entry, ...prev].slice(0, AI_CHAT_PROMPT_HISTORY_LIMIT); + persistPromptHistory(next); + return next; + }); + }; + + const resetPromptHistoryNavigation = () => { + setPromptHistoryIndex(-1); + setSavedPromptDraft(null); + }; + + const applyPromptHistoryEntry = (entry: PromptHistoryEntry, cursor: 'start' | 'end') => { + setInput(entry.prompt); + setAccumulatedMentions(cloneMentions(entry.mentions)); + setMentionActive(false); + queueMicrotask(() => { + resizeTextarea(); + textareaRef?.focus(); + const nextPosition = cursor === 'start' ? 0 : entry.prompt.length; + textareaRef?.setSelectionRange(nextPosition, nextPosition); + }); + }; + + const canNavigatePromptHistory = (direction: 'up' | 'down') => { + if (!textareaRef) return false; + if (textareaRef.selectionStart !== textareaRef.selectionEnd) return false; + const text = input(); + const cursor = textareaRef.selectionStart ?? text.length; + const inHistory = promptHistoryIndex() >= 0; + if (inHistory) return cursor === 0 || cursor === text.length; + if (direction === 'up') return cursor === 0 && text.length === 0; + return false; + }; + + const navigatePromptHistory = (direction: 'up' | 'down') => { + const entries = promptHistory(); + if (entries.length === 0) return false; + + const currentIndex = promptHistoryIndex(); + if (direction === 'up') { + const nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, entries.length - 1); + if (nextIndex === currentIndex) return false; + if (currentIndex < 0) { + setSavedPromptDraft({ + prompt: input(), + mentions: cloneMentions(accumulatedMentions()), + }); + } + setPromptHistoryIndex(nextIndex); + applyPromptHistoryEntry(entries[nextIndex], 'start'); + return true; + } + + if (currentIndex > 0) { + const nextIndex = currentIndex - 1; + setPromptHistoryIndex(nextIndex); + applyPromptHistoryEntry(entries[nextIndex], 'end'); + return true; + } + + if (currentIndex === 0) { + const saved = savedPromptDraft(); + setPromptHistoryIndex(-1); + setSavedPromptDraft(null); + applyPromptHistoryEntry(saved || { prompt: '', mentions: [] }, 'end'); + return true; + } + + return false; + }; + const loadModelSelections = (): Record => { try { const raw = localStorage.getItem(MODEL_SESSION_STORAGE_KEY); @@ -522,6 +694,7 @@ export const AIChat: Component = (props) => { const editQueuedFollowUp = (id: string) => { const queued = chat.takeQueuedFollowUp(id); if (!queued) return; + resetPromptHistoryNavigation(); setEditingQueuedFollowUp(queued); setInput(queued.prompt); restoreQueuedMentions(queued.mentions); @@ -916,6 +1089,7 @@ export const AIChat: Component = (props) => { // Click outside handler to close all dropdowns onMount(() => { + setPromptHistory(loadPromptHistory()); aiChatStore.registerInput?.(textareaRef ?? null); focusComposer(); @@ -1263,6 +1437,8 @@ export const AIChat: Component = (props) => { const sendPromise = hasSendOptions ? chat.sendMessage(prompt, mentionsForAPI, findingId, sendOptions) : chat.sendMessage(prompt, mentionsForAPI, findingId); + addPromptHistoryEntry(prompt, mentions); + resetPromptHistoryNavigation(); const hasRequestHandoffPayload = Boolean(ctx.handoffContext?.trim()) || Boolean(ctx.handoffResources?.length) || @@ -1287,6 +1463,9 @@ export const AIChat: Component = (props) => { // Handle input change with @ mention detection const handleInputChange = (e: InputEvent & { currentTarget: HTMLTextAreaElement }) => { const value = e.currentTarget.value; + if (promptHistoryIndex() >= 0) { + resetPromptHistoryNavigation(); + } setInput(value); resizeTextarea(); @@ -1355,6 +1534,15 @@ export const AIChat: Component = (props) => { } } + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + const direction = e.key === 'ArrowUp' ? 'up' : 'down'; + if (canNavigatePromptHistory(direction) && navigatePromptHistory(direction)) { + e.preventDefault(); + } + return; + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -1365,6 +1553,7 @@ export const AIChat: Component = (props) => { const handleNewConversation = async () => { const started = await chat.newSession(); if (!started) return; + resetPromptHistoryNavigation(); setEditingQueuedFollowUp(null); aiChatStore.clearContext?.(); setShowSessions(false); @@ -1402,6 +1591,7 @@ export const AIChat: Component = (props) => { const session = sessions().find((candidate) => candidate.id === sessionId); const loaded = await chat.loadSession(sessionId); if (!loaded) return; + resetPromptHistoryNavigation(); setEditingQueuedFollowUp(null); const restoredContext = buildSessionHandoffContext(session); if (restoredContext) { @@ -1423,6 +1613,7 @@ export const AIChat: Component = (props) => { updateStoredModel(sessionId, ''); if (chat.sessionId() === sessionId) { chat.clearMessages(); + resetPromptHistoryNavigation(); setEditingQueuedFollowUp(null); } } catch (_error) {