Add delete message button

This commit is contained in:
dwrz
2026-06-12 19:26:30 +00:00
parent 1308954121
commit 0c1346bd77
4 changed files with 348 additions and 49 deletions

View File

@@ -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();
}