From b13cbb505ca39b1bf291864140bf33280fc50de5 Mon Sep 17 00:00:00 2001 From: Mateus Prudencio Date: Thu, 8 Oct 2026 18:31:26 +0000 Subject: [PATCH 01/10] feat(compose): reply with AI from the conversation and an instruction When replying, the AI menu offers "Reply with AI". The user types what the reply should say ("approve the artwork", "decline politely and ask for a new proof") and the AI writes it from the conversation so far: the thread up to the message being answered, oldest first, with the user's own messages marked and the quoted copies of earlier messages cut out. A reply opened from a list that is not grouped into conversations does not carry the thread, so it is looked up from the message being answered; when nothing can be loaded, the quoted original the composer already holds is used. The conversation is read once per composer, so rewording the instruction and generating again only asks the AI. The request stays within the chat proxy's limits: at most ten messages, the newest kept first and the oldest shortened or left out. The conversation is mail other people wrote, so the prompt tells the model to ignore instructions inside it, and Apply now escapes the suggestion before it reaches the editor. Before, a suggestion containing markup, such as a link, became real markup in the user's message; this applies to the existing actions too. The two copies of the suggestion panel (phone and desktop layouts) are now one component. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/ComposeModal.jsx | 210 +++++++++----- .../components/ComposeModal.render.test.js | 139 ++++++++++ frontend/src/locales/cs.json | 7 +- frontend/src/locales/de.json | 7 +- frontend/src/locales/en.json | 7 +- frontend/src/locales/es.json | 7 +- frontend/src/locales/fr.json | 7 +- frontend/src/locales/it.json | 7 +- frontend/src/locales/ko.json | 5 + frontend/src/locales/pl.json | 7 +- frontend/src/locales/ptBR.json | 7 +- frontend/src/locales/ru.json | 7 +- frontend/src/locales/zhCN.json | 7 +- frontend/src/utils/aiReply.js | 240 ++++++++++++++++ frontend/src/utils/aiReply.test.js | 262 ++++++++++++++++++ 15 files changed, 851 insertions(+), 75 deletions(-) create mode 100644 frontend/src/utils/aiReply.js create mode 100644 frontend/src/utils/aiReply.test.js diff --git a/frontend/src/components/ComposeModal.jsx b/frontend/src/components/ComposeModal.jsx index 53ac92715..7f791e1f6 100644 --- a/frontend/src/components/ComposeModal.jsx +++ b/frontend/src/components/ComposeModal.jsx @@ -24,6 +24,7 @@ import { initialComposeFocus, isComposeSendShortcut } from '../utils/composeFrom import { autoListsOf, openAutoRecipients, replyTypeFields, swapAccountFields } from '../utils/autoRecipients.js'; import { UNDO_SEND_SECONDS, undoWindowMs, trackHeldSend, reopenCompose } from '../utils/heldSend.js'; import { openSentMessage } from '../utils/openSentMessage.js'; +import { loadReplyConversation, buildReplyPrompt, aiTextToHtml, MAX_INSTRUCTION_CHARS } from '../utils/aiReply.js'; // Resize an image blob/file to max maxW pixels wide, preserving aspect ratio. // Returns a Promise of a base64 data URL. @@ -324,6 +325,9 @@ export default function ComposeModal() { const [aiStatus, setAiStatus] = useState(null); const [aiPanel, setAiPanel] = useState(null); const aiAbortRef = useRef(null); + // The conversation "Reply with AI" writes from, loaded once and reused when the user rewords + // the instruction. + const aiReplyConversationRef = useRef(null); // Stable idempotency key for the current logical send. Generated on the first send // attempt, reused across retries (so a retry after a lost response dedupes rather than // double-sending), and cleared on success. Fixes audit finding [1]. @@ -734,6 +738,11 @@ export default function ComposeModal() { const handleAiAction = async (action) => { aiAbortRef.current?.abort(); + // "Reply with AI" waits for the user's instruction before it asks for anything. + if (action === 'reply') { + setAiPanel({ action, status: 'input', instruction: '', text: '' }); + return; + } const controller = new AbortController(); aiAbortRef.current = controller; @@ -780,10 +789,56 @@ export default function ComposeModal() { } }; + // Writes a reply from the conversation and the user's instruction. The conversation is read + // first (the panel says so meanwhile), then the reply streams in like the other actions. + const generateAiReply = async (instruction) => { + aiAbortRef.current?.abort(); + const controller = new AbortController(); + aiAbortRef.current = controller; + setAiPanel({ action: 'reply', status: 'reading', instruction, text: '' }); + + try { + if (!aiReplyConversationRef.current) { + const conversation = await loadReplyConversation({ + threadId: composeData?.threadId, + inReplyTo: composeData?.inReplyTo, + accountId: composeData?.accountId, + quotedBody, + originalFrom: composeData?.originalFrom, + accounts, + }, { getThread: api.getThread, getMessageBody: api.getMessageBody, resolveMessage: api.resolveMessage }); + // Kept only when something was found, so a failed load is tried again next time. + if (conversation.length) aiReplyConversationRef.current = conversation; + } + if (controller.signal.aborted) return; + setAiPanel(p => p ? { ...p, status: 'generating' } : p); + const messages = buildReplyPrompt({ + conversation: aiReplyConversationRef.current || [], + instruction, + subject, + senderName: fromAlias?.name || fromAccount?.sender_name || fromAccount?.name, + hasSignature: !!stripHtml(signatureContentRef.current || '').trim(), + }); + const fullText = await api.ai.chat(messages, { + signal: controller.signal, + onDelta: (text) => setAiPanel(p => p ? { ...p, text } : p), + }); + setAiPanel(p => p ? { ...p, status: 'done', text: fullText } : p); + } catch (err) { + if (err.name !== 'AbortError') { + setAiPanel(p => p ? { ...p, status: 'error', text: err.message } : p); + } + } + }; + + const closeAiPanel = () => { + aiAbortRef.current?.abort(); + setAiPanel(null); + }; + const applyAiText = () => { if (!aiPanel?.text || !editor) return; - const html = '

' + aiPanel.text.trim().replace(/\n\n+/g, '

').replace(/\n/g, '
') + '

'; - editor.commands.setContent(html); + editor.commands.setContent(aiTextToHtml(aiPanel.text)); setAiPanel(null); }; @@ -1533,41 +1588,19 @@ export default function ComposeModal() { }} isMobile aiEnabled={!htmlMode && aiStatus?.enabled && aiStatus?.features?.compose} + aiReplyEnabled={isReply} onAiAction={handleAiAction} aiPanelOpen={!!aiPanel} /> {aiPanel && !htmlMode && ( -
-
- {t('compose.toolbar.aiPanelTitle')} - -
- {aiPanel.status === 'generating' && !aiPanel.text && ( -
{t('compose.toolbar.aiGenerating')}
- )} - {aiPanel.status === 'error' ? ( -
{t('compose.toolbar.aiError', { message: aiPanel.text })}
- ) : ( -
- {aiPanel.text} -
- )} - {aiPanel.status === 'done' && ( -
- - -
- )} -
+ setAiPanel(p => p ? { ...p, instruction } : p)} + onGenerateReply={generateAiReply} + onApply={applyAiText} + onClose={closeAiPanel} + /> )} {htmlMode ? (