/** * 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'; 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 isToolEdit = field === 'tool'; const tplId = isToolEdit ? 'tpl-edit-tool-modal' : 'tpl-edit-modal'; const $tpl = this.document.getElementById(tplId); if (!$tpl) return resolve(false); const $modal = $tpl.content.cloneNode(true).firstElementChild; const $overlay = this.document.createElement('div'); $overlay.className = 'modal-overlay'; $overlay.appendChild($modal); if (!isToolEdit) { $modal.querySelector('h2').textContent = field === 'reasoning' ? 'Edit Reasoning' : 'Edit Message'; } const saveBtn = $modal.querySelector('#edit-save'); const closeBtn = $modal.querySelector('.modal__close'); // Populate textarea(s). if (isToolEdit && toolCallId) { this._populateToolEdit(msg, toolCallId, $modal); } else if (field === 'reasoning') { const textarea = $modal.querySelector('#edit-textarea'); textarea.value = msg.reasoning_content || ''; } else { const textarea = $modal.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, $modal, 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 = $modal.querySelector('textarea'); if (firstTextarea) firstTextarea.focus(); }); } // --- Private --- _populateToolEdit(msg, toolCallId, $modal) { const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId); if (!toolCall) return; const $callTextarea = $modal.querySelector('#edit-tool-call'); const $resultTextarea = $modal.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, $modal, field, toolCallId, saveBtn) { const msg = this.conversation.get(messageId); if (!msg) return false; if (field === 'tool' && toolCallId) { return this._saveToolEdit(msg, toolCallId, $modal, saveBtn); } if (field === 'reasoning') { const textarea = $modal.querySelector('#edit-textarea'); msg.reasoning_content = textarea.value; this.conversation.save(); return true; } // Regular message edit. const textarea = $modal.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, $modal, saveBtn) { const toolCall = msg.tool_calls?.find((tc) => tc.id === toolCallId); if (!toolCall) return false; const $callTextarea = $modal.querySelector('#edit-tool-call'); const $resultTextarea = $modal.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; } }