/** * EditModal opens a modal for editing a message (text, reasoning, or * tool call). Reads from and writes to the conversation tree. Returns * a Promise so the App knows when to re-render. */ import { extractText } from './conversation.js'; const ICONS_URL = '/static/icons.svg'; export class EditModal { /** * @param {Object} options * @param {Document} options.document * @param {import('./conversation.js').ConversationTree} options.conversation */ constructor({ document, conversation }) { this.document = document; this.conversation = conversation; } /** * open creates and shows the edit modal for a message. * @param {string} messageId * @param {string} [field] - Optional: 'reasoning' or 'tool' * @param {string} [toolCallId] - Optional, for tool edits * @returns {Promise} true if saved, false if cancelled */ open(messageId, field = null, toolCallId = null) { const msg = this.conversation.get(messageId); if (!msg) return Promise.resolve(false); return new Promise((resolve) => { const $overlay = this.document.createElement('div'); $overlay.className = 'modal-overlay'; const isToolEdit = field === 'tool'; if (isToolEdit) { $overlay.innerHTML = ` `; } else { $overlay.innerHTML = ` `; } const saveBtn = $overlay.querySelector('#edit-save'); const closeBtn = $overlay.querySelector('.modal__close'); // Populate textarea(s). if (isToolEdit && toolCallId) { this._populateToolEdit(msg, toolCallId, $overlay); } else if (field === 'reasoning') { const textarea = $overlay.querySelector('#edit-textarea'); textarea.value = msg.reasoning_content || ''; } else { const textarea = $overlay.querySelector('#edit-textarea'); textarea.value = extractText(msg.content); } const close = () => { $overlay.remove(); this.document.removeEventListener('keydown', handleEscape); resolve(false); }; const save = () => { const saved = this._save( messageId, $overlay, field, toolCallId, saveBtn, ); if (saved) { $overlay.remove(); this.document.removeEventListener('keydown', handleEscape); resolve(true); } }; closeBtn.addEventListener('click', close); saveBtn.addEventListener('click', save); $overlay.addEventListener('click', (e) => { if (e.target === $overlay) close(); }); const handleEscape = (e) => { if (e.key === 'Escape') { close(); } }; this.document.addEventListener('keydown', handleEscape); this.document.body.appendChild($overlay); const firstTextarea = $overlay.querySelector('textarea'); if (firstTextarea) firstTextarea.focus(); }); } // --- Private --- _populateToolEdit(msg, toolCallId, $overlay) { const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId); if (!toolCall) return; const $callTextarea = $overlay.querySelector('#edit-tool-call'); const $resultTextarea = $overlay.querySelector('#edit-tool-result'); try { const args = JSON.parse(toolCall.function?.arguments || '{}'); $callTextarea.value = JSON.stringify( { name: toolCall.function?.name, arguments: args }, null, 2, ); } catch { $callTextarea.value = JSON.stringify( { name: toolCall.function?.name, arguments: toolCall.function?.arguments || '', }, null, 2, ); } for (const [, m] of this.conversation.messagesMap) { if (m.role === 'tool' && m.tool_call_id === toolCallId) { $resultTextarea.value = extractText(m.content) || ''; break; } } } _save(messageId, $overlay, field, toolCallId, saveBtn) { const msg = this.conversation.get(messageId); if (!msg) return false; if (field === 'tool' && toolCallId) { return this._saveToolEdit(msg, toolCallId, $overlay, saveBtn); } if (field === 'reasoning') { const textarea = $overlay.querySelector('#edit-textarea'); msg.reasoning_content = textarea.value; this.conversation.save(); return true; } // Regular message edit. const textarea = $overlay.querySelector('#edit-textarea'); const newText = textarea.value; if (Array.isArray(msg.content)) { const textPart = msg.content.find((p) => p.type === 'text'); if (textPart) { textPart.text = newText; } else { msg.content.unshift({ type: 'text', text: newText }); } } else { msg.content = newText; } this.conversation.save(); return true; } _saveToolEdit(msg, toolCallId, $overlay, saveBtn) { const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId); if (!toolCall) return false; const $callTextarea = $overlay.querySelector('#edit-tool-call'); const $resultTextarea = $overlay.querySelector('#edit-tool-result'); let parsedCall; try { parsedCall = JSON.parse($callTextarea.value); } catch { $callTextarea.classList.add('modal__textarea--error'); saveBtn.classList.add('modal__save--error'); setTimeout(() => { saveBtn.classList.remove('modal__save--error'); }, 500); return false; } if (parsedCall.name) { toolCall.function.name = parsedCall.name; } if (parsedCall.arguments !== undefined) { toolCall.function.arguments = typeof parsedCall.arguments === 'string' ? parsedCall.arguments : JSON.stringify(parsedCall.arguments); } for (const [, m] of this.conversation.messagesMap) { if (m.role === 'tool' && m.tool_call_id === toolCallId) { const newText = $resultTextarea.value; if (Array.isArray(m.content)) { const textPart = m.content.find((p) => p.type === 'text'); if (textPart) { textPart.text = newText; } else { m.content.unshift({ type: 'text', text: newText }); } } else { m.content = newText; } break; } } this.conversation.save(); return true; } }