From 0c1346bd77fb1e3750fb219e24790bebd26e5c90 Mon Sep 17 00:00:00 2001 From: dwrz Date: Fri, 12 Jun 2026 19:26:30 +0000 Subject: [PATCH] Add delete message button --- internal/service/static/icons.svg | 6 + internal/service/static/main.css | 17 + internal/service/static/main.js | 358 +++++++++++++++--- .../service/templates/static/templates.gohtml | 16 + 4 files changed, 348 insertions(+), 49 deletions(-) diff --git a/internal/service/static/icons.svg b/internal/service/static/icons.svg index 141ec2d..fe4bc3a 100644 --- a/internal/service/static/icons.svg +++ b/internal/service/static/icons.svg @@ -105,6 +105,12 @@ + + + + + + diff --git a/internal/service/static/main.css b/internal/service/static/main.css index c661a91..37efbee 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -579,6 +579,23 @@ body { color: var(--color-primary); } +@media (hover: hover) { + .message__action-btn--delete:hover { + color: var(--color-red); + } +} + +/* Pending delete: yellow highlight, click again to confirm */ +.message__action-btn--delete.pending { + color: var(--color-yellow); +} + +@media (hover: hover) { + .message__action-btn--delete.pending:hover { + color: var(--color-yellow); + } +} + .message__actions { display: flex; align-items: center; diff --git a/internal/service/static/main.js b/internal/service/static/main.js index b1b7e9c..bb6c015 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -24,7 +24,6 @@ class Odidere { this.currentAudio = null; this.currentAudioUrl = null; this.currentController = null; - this.history = []; this.isProcessing = false; this.isRecording = false; this.isMuted = false; @@ -33,6 +32,11 @@ class Odidere { this.isStopPending = false; this.mediaRecorder = null; this.wakeLock = null; + this.pendingDeleteId = null; + // Conversation state + this.messagesMap = new Map(); + this.leafId = null; + this.rootId = null; // Auto-scroll: enabled by default, disabled when user scrolls up, // re-enabled when they scroll back to bottom or send a new message. @@ -105,7 +109,10 @@ class Odidere { } this.#setLoadingState(false); - this.history = []; + this.pendingDeleteId = null; + this.messagesMap.clear(); + this.leafId = null; + this.rootId = null; this.isAutoScrollEnabled = true; this._lastScrollTop = 0; localStorage.removeItem(STORAGE_KEY); @@ -167,6 +174,7 @@ class Odidere { // scroll-to-top. this.$scroll.addEventListener('click', () => this.#handleScrollClick()); this.document.addEventListener('click', (e) => this.#handleScrollCancel(e)); + this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e)); // File attachment. this.$attach.addEventListener('click', () => this.$fileInput.click()); @@ -223,6 +231,10 @@ class Odidere { ); } + // ==================== + // CONVERSATION TREE + // ===================== + /** * #loadHistory loads chat history from localStorage and renders it. */ @@ -231,14 +243,232 @@ class Odidere { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return; - this.history = JSON.parse(stored); - this.#renderMessages(this.history); + const parsed = JSON.parse(stored); + + // Ensure messages have parent/children fields (defensive). + for (const msg of parsed.messages) { + if (msg.parent === undefined) msg.parent = null; + if (msg.children === undefined) msg.children = []; + } + + this.messagesMap = new Map(parsed.messages.map((m) => [m.id, m])); + this.leafId = parsed.leafId; + this.rootId = parsed.rootId; + + // Render the active path. + const activePath = this.getActivePath(); + this.#renderMessages(activePath); } catch (e) { console.error('failed to load history:', e); - this.history = []; + this.messagesMap.clear(); + this.leafId = null; + this.rootId = null; } } + /** + * #saveToStorage persists the tree to localStorage. + */ + #saveToStorage() { + const messages = Array.from(this.messagesMap.values()); + const data = { + messages, + leafId: this.leafId, + rootId: this.rootId, + }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.error('failed to save history:', e); + } + } + + /** + * getActivePath resolves the ordered array of messages from root to leaf + * by walking parent pointers from leafId. + * @returns {Object[]} ordered messages + */ + getActivePath() { + if (!this.leafId) return []; + const path = []; + let current = this.leafId; + while (current) { + const msg = this.messagesMap.get(current); + if (!msg) break; + path.push(msg); + current = msg.parent; + } + path.reverse(); + return path; + } + + /** + * #appendHistory adds messages to the tree. Each new message is a child + * of the previous one (or the current leaf for the first). + * Only the last message receives the metadata. + * Mutates the passed-in message objects in place (adds id, parent, children, + * meta) and stores the same references in messagesMap. Callers should use + * the returned messages for rendering to ensure the delete handler captures + * the correct IDs. + * @param {Object[]} messages + * @param {Object} [meta] + * @returns {Object[]} the same message objects, now enriched + */ + #appendHistory(messages, meta = {}) { + let prevId = this.leafId; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + + // Generate ID if not present. + if (!msg.id) { + msg.id = crypto.randomUUID(); + } + + // Attach metadata only to the final message. + if (i === messages.length - 1) { + msg.meta = meta; + } + + // Link to the tree. + msg.parent = prevId; + msg.children = []; + + if (prevId) { + const parent = this.messagesMap.get(prevId); + if (parent) { + parent.children.push(msg.id); + } + } else { + // First message in the conversation. + this.rootId = msg.id; + } + + this.messagesMap.set(msg.id, msg); + this.leafId = msg.id; + prevId = msg.id; + } + + this.#saveToStorage(); + return messages; + } + + /** + * deleteMessage removes a message from the tree and reparents its + * children to the grandparent. Also handles tool call / tool result + * pairing. When deleting the root, the first child becomes the new root + * and any remaining children are reparented under it. + * @param {string} messageId + */ + deleteMessage(messageId) { + const msg = this.messagesMap.get(messageId); + if (!msg) return; + + const parentId = msg.parent; // null if this is the root + const parent = parentId ? this.messagesMap.get(parentId) : null; + + // Identify tool result messages that belong to this assistant message. + const idsToDelete = new Set([messageId]); + if (msg.role === 'assistant' && msg.tool_calls?.length > 0) { + const toolCallIds = new Set(msg.tool_calls.map((tc) => tc.id)); + for (const [id, m] of this.messagesMap) { + if (m.role === 'tool' && toolCallIds.has(m.tool_call_id)) { + idsToDelete.add(id); + } + } + } + + // Collect all children that need reparenting. + const childrenToReparent = []; + for (const deleteId of idsToDelete) { + const delMsg = this.messagesMap.get(deleteId); + if (delMsg?.children) { + childrenToReparent.push(...delMsg.children); + } + } + + // Remove deleted messages from the map. + for (const deleteId of idsToDelete) { + this.messagesMap.delete(deleteId); + } + + // Remove deleted IDs from parent's children. + if (parent) { + parent.children = parent.children.filter((c) => !idsToDelete.has(c)); + } + + // Reparent children of deleted messages to the grandparent. + // When deleting the root, the first child becomes the new root and the + // remaining children are reparented under it. + if (parent) { + for (const childId of childrenToReparent) { + const child = this.messagesMap.get(childId); + if (child) { + child.parent = parentId; + if (!parent.children.includes(childId)) { + parent.children.push(childId); + } + } + } + } else if (childrenToReparent.length > 0) { + // Root deletion: first child becomes the new root, rest become its children. + const newRootId = childrenToReparent[0]; + const newRoot = this.messagesMap.get(newRootId); + if (newRoot) { + newRoot.parent = null; + for (let i = 1; i < childrenToReparent.length; i++) { + const childId = childrenToReparent[i]; + const child = this.messagesMap.get(childId); + if (child) { + child.parent = newRootId; + if (!newRoot.children.includes(childId)) { + newRoot.children.push(childId); + } + } + } + } + this.rootId = newRootId; + } else { + this.rootId = null; + } + + // Update leafId if it was deleted or was a descendant of a deleted message. + if (idsToDelete.has(this.leafId)) { + if (parentId) { + this.#updateLeafId(parentId); + } else if (childrenToReparent.length > 0) { + this.#updateLeafId(childrenToReparent[0]); + } else { + this.leafId = null; + } + } + + // Remove the DOM element(s). + for (const deleteId of idsToDelete) { + const $el = this.$chat.querySelector(`[data-id="${deleteId}"]`); + if ($el) $el.remove(); + } + + this.#saveToStorage(); + } + + /** + * #updateLeafId walks the tree from a starting node to find the deepest + * leaf by following the first child at each step. Used when the active + * leaf is deleted and a new one must be chosen. With branching, this + * picks the first branch; future work may track which branch was active. + * @param {string} startId + */ + #updateLeafId(startId) { + let current = startId; + while (current) { + const msg = this.messagesMap.get(current); + if (!msg || !msg.children || msg.children.length === 0) break; + // Follow the first child. With branching, this picks the first branch. + current = msg.children[0]; + } + this.leafId = current; + } + // ==================== // EVENT HANDLERS // ==================== @@ -519,6 +749,57 @@ class Odidere { this.$send.classList.remove('pending'); } + /** + * #handleDeleteClick toggles the pending delete state on first press + * (yellow highlight), executes the delete on second press. Clicking + * anywhere outside the button cancels the pending state. + * @param {MouseEvent} event + * @param {string} messageId + */ + #handleDeleteClick(event, messageId) { + event.stopPropagation(); + const $btn = event.currentTarget; + + if (this.pendingDeleteId === messageId) { + this.pendingDeleteId = null; + $btn.classList.remove('pending'); + this.deleteMessage(messageId); + } else { + // Clear any previously pending delete button. + this.#clearPendingDelete(); + this.pendingDeleteId = messageId; + $btn.classList.add('pending'); + } + } + + /** + * #clearPendingDelete clears the pending delete state and removes the + * yellow highlight from the delete button. + */ + #clearPendingDelete() { + if (!this.pendingDeleteId) return; + const $msg = this.$chat.querySelector( + `[data-id="${this.pendingDeleteId}"]`, + ); + if ($msg) { + const $btn = $msg.querySelector('[data-action="delete"]'); + if ($btn) $btn.classList.remove('pending'); + } + this.pendingDeleteId = null; + } + + /** + * #handleDeleteCancel clears the pending delete state when the user + * clicks anywhere outside the delete button. + * @param {MouseEvent} event + */ + #handleDeleteCancel(event) { + if (!this.pendingDeleteId) return; + const $btn = event.target.closest('[data-action="delete"]'); + if ($btn) return; + this.#clearPendingDelete(); + } + /** * #stopConversation aborts the current streaming request and audio. */ @@ -787,9 +1068,10 @@ class Odidere { return; } - // Build messages array: history + current user message. + // Build messages array from active path + current user message. + const activePath = this.getActivePath(); const messages = [ - ...this.history.map(({ id, meta, ...msg }) => msg), + ...activePath.map(({ parent, children, id, meta, ...msg }) => msg), userMessage, ]; @@ -810,10 +1092,10 @@ class Odidere { this.#clearAttachments(); // For text-only requests (no audio), add to history and render - // immediately. + // immediately. Use the returned messages (with IDs) for rendering. if (!audio) { - this.#appendHistory([userMessage]); - this.#renderMessages([userMessage]); + const appended = this.#appendHistory([userMessage]); + this.#renderMessages(appended); } const res = await fetch(STREAM_ENDPOINT, { @@ -908,13 +1190,12 @@ class Odidere { message.tool_calls?.length > 0 && !message.content ) { + const appended = this.#appendHistory([message]); pendingTools = { - assistant: message, + assistant: appended[0], results: [], expected: message.tool_calls.length, }; - // Add to history (server needs it) but don't render. - this.#appendHistory([message]); continue; } @@ -924,9 +1205,8 @@ class Odidere { pendingTools && pendingTools.assistant ) { - pendingTools.results.push(message); - // Add to history (server needs it) but don't render yet. - this.#appendHistory([message]); + const appended = this.#appendHistory([message]); + pendingTools.results.push(appended[0]); // Once all tool results are in, render the combined message. if (pendingTools.results.length >= pendingTools.expected) { @@ -943,8 +1223,8 @@ class Odidere { const meta = {}; if (event.voice) meta.voice = event.voice; - this.#appendHistory([message], meta); - this.#renderMessages([message], meta); + const appended = this.#appendHistory([message], meta); + this.#renderMessages(appended, meta); // For the final assistant message with audio, play it. if (event.audio) { @@ -1028,38 +1308,8 @@ class Odidere { } // ==================== - // STATE & HISTORY + // STATE // ==================== - /** - * #appendHistory adds multiple messages to history. - * Only the last message receives the metadata. - * @param {Object[]} messages - * @param {Object} [meta] - */ - #appendHistory(messages, meta = {}) { - for (let i = 0; i < messages.length; i++) { - const msg = { ...messages[i] }; - - // Generate ID if not present. - if (!msg.id) { - msg.id = crypto.randomUUID(); - } - - // Attach metadata only to the final message. - if (i === messages.length - 1) { - msg.meta = meta; - } - - this.history.push(msg); - } - - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history)); - } catch (e) { - console.error('failed to save history:', e); - } - } - /** * #setLoadingState updates UI to reflect loading state. * @param {boolean} loading @@ -1426,6 +1676,11 @@ class Odidere { this.#copyToClipboard($copy, content), ); + const $delete = $msg.querySelector('[data-action="delete"]'); + $delete.addEventListener('click', (e) => + this.#handleDeleteClick(e, message.id), + ); + this.$chat.appendChild($msg); this.#scrollToBottom(); } @@ -1502,6 +1757,11 @@ class Odidere { this.#copyToClipboard($copy, copyText), ); + const $delete = $msg.querySelector('[data-action="delete"]'); + $delete.addEventListener('click', (e) => + this.#handleDeleteClick(e, message.id), + ); + this.$chat.appendChild($msg); this.#scrollToBottom(); } diff --git a/internal/service/templates/static/templates.gohtml b/internal/service/templates/static/templates.gohtml index 2eb2f4a..c95ea06 100644 --- a/internal/service/templates/static/templates.gohtml +++ b/internal/service/templates/static/templates.gohtml @@ -6,6 +6,14 @@
+