Refactor UI
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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<boolean>} 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 = `
|
||||
<div class="modal">
|
||||
<div class="modal__header">
|
||||
<h2>Edit Tool Call</h2>
|
||||
<button type="button" class="modal__close" aria-label="Cancel">
|
||||
<svg class="icon"><use href="${ICONS_URL}#close"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<details class="modal__collapsible">
|
||||
<summary class="modal__collapsible-summary">Tool Call (JSON)</summary>
|
||||
<textarea class="modal__textarea" id="edit-tool-call" rows="8"></textarea>
|
||||
</details>
|
||||
<details class="modal__collapsible">
|
||||
<summary class="modal__collapsible-summary">Tool Result</summary>
|
||||
<textarea class="modal__textarea" id="edit-tool-result" rows="6"></textarea>
|
||||
</details>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button type="button" class="modal__save" id="edit-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
$overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal__header">
|
||||
<h2>${field === 'reasoning' ? 'Edit Reasoning' : 'Edit Message'}</h2>
|
||||
<button type="button" class="modal__close" aria-label="Cancel">
|
||||
<svg class="icon"><use href="${ICONS_URL}#close"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<textarea class="modal__textarea" id="edit-textarea" rows="10"></textarea>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button type="button" class="modal__save" id="edit-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user