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 @@