diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg
index 9a71bfb..584fe48 100644
--- a/internal/service/static/icons.svg
+++ b/internal/service/static/icons.svg
@@ -124,4 +124,8 @@
+
+
+
+
diff --git a/internal/service/static/main.css b/internal/service/static/main.css
index 680d635..656be96 100644
--- a/internal/service/static/main.css
+++ b/internal/service/static/main.css
@@ -1023,3 +1023,162 @@ body {
.footer__toolbar-context--critical {
color: var(--color-red);
}
+
+/* ==================== */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ padding: var(--s2);
+}
+
+.modal {
+ background: var(--color-surface);
+ border-radius: var(--radius);
+ border: var(--border-width) solid var(--color-border);
+ width: 100%;
+ max-width: 600px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--s-1) var(--s1);
+ border-bottom: 1px solid var(--color-border-light);
+ flex-shrink: 0;
+}
+
+.modal__header h2 {
+ font-size: var(--s0);
+ font-weight: 600;
+ margin: 0;
+}
+
+.modal__close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: var(--s-2);
+ color: var(--color-text-muted);
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.modal__close .icon {
+ width: var(--icon-size);
+ height: var(--icon-size);
+}
+
+.modal__close:hover {
+ color: var(--color-text);
+}
+
+.modal__body {
+ padding: var(--s1);
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.modal__textarea {
+ width: 100%;
+ min-height: 200px;
+ height: 100%;
+ padding: var(--s-1);
+ font-family: var(--font-mono);
+ font-size: var(--s-1);
+ line-height: 1.5;
+ border: var(--border-width) solid var(--color-border-light);
+ border-radius: var(--radius);
+ resize: none;
+ outline: none;
+}
+
+.modal__textarea:focus {
+ border-color: var(--color-primary);
+}
+
+.modal__textarea--error {
+ border-color: var(--color-red);
+}
+
+.modal__footer {
+ padding: var(--s-1) var(--s1);
+ border-top: 1px solid var(--color-border-light);
+ display: flex;
+ justify-content: flex-end;
+ flex-shrink: 0;
+}
+
+.modal__save {
+ padding: var(--s-1) var(--s2);
+ font-size: var(--s0);
+ background: var(--color-primary);
+ color: white;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+
+.modal__save:hover {
+ background: var(--color-primary-hover);
+}
+
+.modal__save--error {
+ background: var(--color-red);
+ animation: modal-flash 0.3s ease-in-out;
+}
+
+@keyframes modal-flash {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Mobile: full-screen modal */
+@media (max-width: 768px) {
+ .modal-overlay {
+ padding: 0;
+ align-items: stretch;
+ }
+
+ .modal {
+ max-width: 100%;
+ max-height: 100%;
+ height: 100%;
+ border-radius: 0;
+ border: none;
+ }
+
+ .modal__header {
+ padding: var(--s-1) var(--s1);
+ }
+
+ .modal__body {
+ padding: var(--s-1) var(--s1);
+ }
+
+ .modal__footer {
+ padding: var(--s-1) var(--s1);
+ }
+
+ .modal__save {
+ width: 100%;
+ padding: var(--s-1) var(--s2);
+ font-size: var(--s0);
+ }
+}
diff --git a/internal/service/static/main.js b/internal/service/static/main.js
index 2a69543..033f5e2 100644
--- a/internal/service/static/main.js
+++ b/internal/service/static/main.js
@@ -937,6 +937,389 @@ class Odidere {
this.#clearPendingDelete();
}
+ // ====================
+ // EDIT MODAL
+ // ====================
+
+ /**
+ * #handleEditClick opens the edit modal for the given message.
+ * @param {MouseEvent} event
+ * @param {string} messageId
+ */
+ #handleEditClick(event, messageId) {
+ event.stopPropagation();
+ this.#openEditModal(messageId);
+ }
+
+ /**
+ * #openEditModal creates and shows the edit modal for a message.
+ * @param {string} messageId
+ */
+ #openEditModal(messageId) {
+ const msg = this.messagesMap.get(messageId);
+ if (!msg) return;
+
+ // Build modal DOM.
+ const $overlay = this.document.createElement('div');
+ $overlay.className = 'modal-overlay';
+ $overlay.innerHTML = `
+
+ `;
+
+ // Determine if this message should be edited as JSON.
+ const isJson = this.#shouldShowJson(msg);
+ const textarea = $overlay.querySelector('#edit-textarea');
+ const saveBtn = $overlay.querySelector('#edit-save');
+ const closeBtn = $overlay.querySelector('.modal__close');
+
+ // Populate textarea.
+ if (isJson) {
+ textarea.value = JSON.stringify(
+ this.#extractEditableFields(msg),
+ null,
+ 2,
+ );
+ } else {
+ textarea.value = this.#extractText(msg.content);
+ }
+
+ // Adjust textarea size for JSON.
+ if (isJson) {
+ textarea.rows = 20;
+ }
+
+ // Bind events.
+ closeBtn.addEventListener('click', () => this.#closeEditModal());
+ saveBtn.addEventListener('click', () =>
+ this.#saveEdit(messageId, textarea, isJson, saveBtn),
+ );
+
+ // Close on overlay click (outside modal).
+ $overlay.addEventListener('click', (e) => {
+ if (e.target === $overlay) this.#closeEditModal();
+ });
+
+ // Close on Escape.
+ const handleEscape = (e) => {
+ if (e.key === 'Escape') {
+ this.#closeEditModal();
+ this.document.removeEventListener('keydown', handleEscape);
+ }
+ };
+ this.document.addEventListener('keydown', handleEscape);
+
+ this.document.body.appendChild($overlay);
+ textarea.focus();
+ }
+
+ /**
+ * #closeEditModal removes the modal from the DOM.
+ */
+ #closeEditModal() {
+ const $overlay = this.document.querySelector('.modal-overlay');
+ if ($overlay) $overlay.remove();
+ }
+
+ /**
+ * #shouldShowJson determines if a message should be edited as JSON.
+ * Assistant messages with reasoning or tool calls are shown as JSON.
+ * @param {Object} message
+ * @returns {boolean}
+ */
+ #shouldShowJson(message) {
+ if (message.role !== 'assistant') return false;
+ if (message.reasoning_content) return true;
+ if (message.tool_calls?.length > 0) return true;
+ return false;
+ }
+
+ /**
+ * #extractEditableFields returns the editable fields of a message,
+ * stripping tree-structural fields (id, parent, children, meta).
+ * @param {Object} message
+ * @returns {Object}
+ */
+ #extractEditableFields(message) {
+ const { id, parent, children, meta, ...editable } = message;
+
+ if (message.role === 'assistant' && message.tool_calls?.length > 0) {
+ const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id));
+ const toolResults = [];
+ for (const result of this.messagesMap.values()) {
+ if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) {
+ toolResults.push(this.#extractEditableFields(result));
+ }
+ }
+ if (toolResults.length > 0) {
+ editable.tool_results = toolResults;
+ }
+ }
+
+ return editable;
+ }
+
+ /**
+ * #saveEdit validates and applies the edited content to a message.
+ * @param {string} messageId
+ * @param {HTMLTextAreaElement} textarea
+ * @param {boolean} isJson
+ * @param {HTMLButtonElement} saveBtn
+ */
+ #saveEdit(messageId, textarea, isJson, saveBtn) {
+ const msg = this.messagesMap.get(messageId);
+ if (!msg) return;
+
+ if (isJson) {
+ // JSON mode: parse and validate.
+ let parsed;
+ try {
+ parsed = JSON.parse(textarea.value);
+ } catch (_e) {
+ // Invalid JSON: show error, do nothing else.
+ textarea.classList.add('modal__textarea--error');
+ saveBtn.classList.add('modal__save--error');
+ setTimeout(() => {
+ saveBtn.classList.remove('modal__save--error');
+ }, 500);
+ return;
+ }
+
+ // Apply parsed fields to the message.
+ // Preserve structural fields (id, parent, children, meta).
+ // tool_results is a synthetic editable field for hidden tool result
+ // messages that are displayed inside assistant tool-call blocks.
+ for (const key of Object.keys(parsed)) {
+ if (
+ ['id', 'parent', 'children', 'meta', 'tool_results'].includes(key)
+ ) {
+ continue;
+ }
+ msg[key] = parsed[key];
+ }
+
+ if (msg.role === 'assistant' && Array.isArray(parsed.tool_results)) {
+ const toolCallIds = new Set((msg.tool_calls ?? []).map((tc) => tc.id));
+ const existingResults = [];
+ for (const result of this.messagesMap.values()) {
+ if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) {
+ existingResults.push(result);
+ }
+ }
+
+ parsed.tool_results.forEach((parsedResult, index) => {
+ const result =
+ existingResults.find(
+ (r) => r.tool_call_id === parsedResult?.tool_call_id,
+ ) ?? existingResults[index];
+ if (!result || !parsedResult || typeof parsedResult !== 'object') {
+ return;
+ }
+ for (const key of Object.keys(parsedResult)) {
+ if (['id', 'parent', 'children', 'meta'].includes(key)) continue;
+ result[key] = parsedResult[key];
+ }
+ });
+ }
+ } else {
+ // Plain text mode: update content.
+ const newText = textarea.value;
+ if (Array.isArray(msg.content)) {
+ // Multipart: update first text part or create one.
+ const textPart = msg.content.find((p) => p.type === ContentTypeText);
+ if (textPart) {
+ textPart.text = newText;
+ } else {
+ msg.content.unshift({ type: ContentTypeText, text: newText });
+ }
+ } else {
+ msg.content = newText;
+ }
+ }
+
+ // Persist.
+ this.#saveToStorage();
+
+ // Re-render the message in place.
+ this.#reRenderMessage(msg);
+
+ // Close modal.
+ this.#closeEditModal();
+ }
+
+ /**
+ * #reRenderMessage replaces the DOM element for a message.
+ * @param {Object} message
+ */
+ #reRenderMessage(message) {
+ const $old = this.$chat.querySelector(`[data-id="${message.id}"]`);
+ if (!$old) return;
+
+ if (message.role === 'user') {
+ // Render user message.
+ const $msg =
+ this.$tplUserMessage.content.cloneNode(true).firstElementChild;
+ if (message.id) $msg.dataset.id = message.id;
+
+ const content = this.#extractText(message.content);
+ $msg.querySelector('.message__content').textContent = content;
+
+ // Populate debug panel.
+ const $dl = $msg.querySelector('.message__debug-list');
+ if (message.meta?.language)
+ this.#appendDebugRow($dl, 'Language', message.meta.language);
+ if (message.meta?.voice)
+ this.#appendDebugRow($dl, 'Voice', message.meta.voice);
+ if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
+
+ // Bind action buttons.
+ const $inspect = $msg.querySelector('[data-action="inspect"]');
+ $inspect.addEventListener('click', () => {
+ const isOpen = $msg.classList.toggle('message--debug-open');
+ $inspect.setAttribute('aria-expanded', String(isOpen));
+ });
+
+ const $copy = $msg.querySelector('[data-action="copy"]');
+ $copy.addEventListener('click', () =>
+ this.#copyToClipboard($copy, content),
+ );
+
+ const $delete = $msg.querySelector('[data-action="delete"]');
+ $delete.addEventListener('click', (e) =>
+ this.#handleDeleteClick(e, message.id),
+ );
+
+ const $edit = $msg.querySelector('[data-action="edit"]');
+ $edit.addEventListener('click', (e) =>
+ this.#handleEditClick(e, message.id),
+ );
+
+ $old.replaceWith($msg);
+ } else if (message.role === 'assistant') {
+ // Render assistant message.
+ const $msg =
+ this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
+ if (message.id) $msg.dataset.id = message.id;
+
+ const $body = $msg.querySelector('.message__body');
+ const $content = $msg.querySelector('.message__content');
+
+ // Reasoning block.
+ if (message.reasoning_content) {
+ const $reasoning = this.#createCollapsibleBlock(
+ 'reasoning',
+ 'Reasoning',
+ message.reasoning_content,
+ );
+ $body.insertBefore($reasoning, $content);
+ }
+
+ // Tool call blocks.
+ if (message.tool_calls?.length > 0) {
+ const toolResultMap = new Map();
+ const toolCallIds = new Set(message.tool_calls.map((tc) => tc.id));
+ for (const result of this.messagesMap.values()) {
+ if (result.role === 'tool' && toolCallIds.has(result.tool_call_id)) {
+ toolResultMap.set(result.tool_call_id, result);
+ }
+ }
+ for (const toolCall of message.tool_calls) {
+ const $toolBlock = this.#createToolCallBlock(
+ toolCall,
+ toolResultMap.get(toolCall.id),
+ );
+ $body.insertBefore($toolBlock, $content);
+ }
+ }
+
+ // Main content.
+ if (this.#extractText(message.content)) {
+ $content.textContent = this.#extractText(message.content);
+ } else {
+ $content.remove();
+ }
+
+ // Populate debug panel.
+ const $dl = $msg.querySelector('.message__debug-list');
+ if (message.meta?.voice)
+ this.#appendDebugRow($dl, 'Voice', message.meta.voice);
+ if (message.meta?.usage) {
+ const u = message.meta.usage;
+ this.#appendDebugRow($dl, 'Prompt tokens', String(u.prompt_tokens));
+ this.#appendDebugRow(
+ $dl,
+ 'Completion tokens',
+ String(u.completion_tokens),
+ );
+ this.#appendDebugRow($dl, 'Total tokens', String(u.total_tokens));
+ }
+ if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');
+
+ // Bind action buttons.
+ const $inspect = $msg.querySelector('[data-action="inspect"]');
+ $inspect.addEventListener('click', () => {
+ const isOpen = $msg.classList.toggle('message--debug-open');
+ $inspect.setAttribute('aria-expanded', String(isOpen));
+ });
+
+ // Play button.
+ const $play = $msg.querySelector('[data-action="play"]');
+ if (this.#extractText(message.content)) {
+ const voice = message.meta?.voice || this.$voice.value || '';
+ $play.addEventListener('click', (e) =>
+ this.#handlePlayClick(
+ e,
+ message.id,
+ this.#extractText(message.content),
+ voice,
+ ),
+ );
+ } else {
+ $play.remove();
+ }
+
+ // Copy button.
+ const $copy = $msg.querySelector('[data-action="copy"]');
+ $copy.addEventListener('click', () => {
+ const copyParts = [];
+ if (message.reasoning_content)
+ copyParts.push(message.reasoning_content);
+ for (const tc of message.tool_calls ?? []) {
+ const name = tc.function?.name || 'unknown';
+ const args = tc.function?.arguments || '{}';
+ copyParts.push(`${name}(${args})`);
+ }
+ if (this.#extractText(message.content))
+ copyParts.push(this.#extractText(message.content));
+ this.#copyToClipboard($copy, copyParts.join('\n\n'));
+ });
+
+ const $delete = $msg.querySelector('[data-action="delete"]');
+ $delete.addEventListener('click', (e) =>
+ this.#handleDeleteClick(e, message.id),
+ );
+
+ const $edit = $msg.querySelector('[data-action="edit"]');
+ $edit.addEventListener('click', (e) =>
+ this.#handleEditClick(e, message.id),
+ );
+
+ $old.replaceWith($msg);
+ }
+ }
+
/**
* #stopConversation aborts the current streaming request and audio.
*/
@@ -2370,6 +2753,11 @@ class Odidere {
this.#handleDeleteClick(e, message.id),
);
+ const $edit = $msg.querySelector('[data-action="edit"]');
+ $edit.addEventListener('click', (e) =>
+ this.#handleEditClick(e, message.id),
+ );
+
this.$chat.insertBefore($msg, this.$chatLoading);
this.#scrollToBottom();
}
@@ -2579,6 +2967,11 @@ class Odidere {
this.#handleDeleteClick(e, message.id),
);
+ const $edit = $msg.querySelector('[data-action="edit"]');
+ $edit.addEventListener('click', (e) =>
+ this.#handleEditClick(e, message.id),
+ );
+
$streaming.replaceWith($msg);
this.#scrollToBottom();
return $msg;
@@ -2721,6 +3114,11 @@ class Odidere {
this.#handleDeleteClick(e, message.id),
);
+ const $edit = $msg.querySelector('[data-action="edit"]');
+ $edit.addEventListener('click', (e) =>
+ this.#handleEditClick(e, message.id),
+ );
+
this.$chat.insertBefore($msg, this.$chatLoading);
this.#scrollToBottom();
}
diff --git a/internal/service/templates/static/templates.gohtml b/internal/service/templates/static/templates.gohtml
index a4103a5..bd58a2a 100644
--- a/internal/service/templates/static/templates.gohtml
+++ b/internal/service/templates/static/templates.gohtml
@@ -23,6 +23,14 @@
>
+
+