From a70634a1e93eeff18aa0de34f1d708f6e7a746b3 Mon Sep 17 00:00:00 2001 From: dwrz Date: Sun, 1 Nov 2026 11:42:15 +0000 Subject: [PATCH] Replace click routing with direct button handlers --- internal/service/static/js/ui.js | 393 +++++++++--------- .../templates/static/footer/compose.gohtml | 4 - .../templates/static/footer/toolbar.gohtml | 6 - .../service/templates/static/templates.gohtml | 53 +-- 4 files changed, 211 insertions(+), 245 deletions(-) diff --git a/internal/service/static/js/ui.js b/internal/service/static/js/ui.js index c1c0e61..c69b219 100644 --- a/internal/service/static/js/ui.js +++ b/internal/service/static/js/ui.js @@ -95,6 +95,7 @@ export class UI { // Toolbar. this.$context = document.getElementById('context'); this.$reset = document.getElementById('reset'); + this.$scrollUp = document.getElementById('scroll-up'); this.$scrollDown = document.getElementById('scroll-down'); this.$mute = document.getElementById('mute'); this.$settings = document.getElementById('settings'); @@ -214,6 +215,11 @@ export class UI { // stay clickable whatever the last enabled state was. this.$send.disabled = false; } else { + // A pending stop confirmation must not stay highlighted on the + // button after the run ends and the button returns to send. + if (this.$pendingConfirm?.$target === this.$send) { + this._clearConfirm(); + } // Restore the enabled state recorded before the run. A caller // cannot leave the button disabled by forgetting to call // updateSendEnabled after this. @@ -277,9 +283,8 @@ export class UI { const $name = $chip.querySelector('.compose__attachment-name'); $name.textContent = file.name; $name.title = file.name; - $chip - .querySelector('.compose__attachment-remove') - .addEventListener('click', () => onRemove(index)); + $chip.querySelector('.compose__attachment-remove').onclick = () => + onRemove(index); this.$attachments.appendChild($chip); } } @@ -402,6 +407,12 @@ export class UI { ` [data-parent-id="${CSS.escape(id)}"]`, ) .forEach(($bubble) => { + if ( + this.$pendingConfirm && + $bubble.contains(this.$pendingConfirm.$target) + ) { + this._clearConfirm(); + } $bubble.remove(); }); } @@ -543,6 +554,7 @@ export class UI { $content.innerHTML = 'System Default'; } + this._bindMessageActions($bubble, { id: 'system' }); this.$chat.insertBefore($bubble, this.$chatLoading); } @@ -557,6 +569,7 @@ export class UI { this.$tplErrorMessage.content.cloneNode(true).firstElementChild; $bubble.querySelector('.message__content').textContent = message; $bubble.dataset.copyText = message; + this._bindMessageActions($bubble, { isError: true }); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } @@ -567,7 +580,15 @@ export class UI { */ clearChat() { for (const $bubble of [...this.$chat.children]) { - if ($bubble !== this.$chatLoading) $bubble.remove(); + if ($bubble !== this.$chatLoading) { + if ( + this.$pendingConfirm && + $bubble.contains(this.$pendingConfirm.$target) + ) { + this._clearConfirm(); + } + $bubble.remove(); + } } } @@ -634,7 +655,7 @@ export class UI { const $bubble = this.$chat.querySelector( `[data-id="${CSS.escape(messageId)}"]`, ); - const $btn = $bubble?.querySelector('[data-action="play"]'); + const $btn = $bubble?.querySelector('.message__action-btn--play'); if (!$btn) return; $btn.replaceChildren(this._icon(playing ? 'stop' : 'volume')); $btn.classList.toggle('playing', playing); @@ -877,6 +898,12 @@ export class UI { /** closeConversations hides the picker. */ closeConversations() { + if ( + this.$pendingConfirm && + this.$conversationsOverlay.contains(this.$pendingConfirm.$target) + ) { + this._clearConfirm(); + } this.$conversationsOverlay.classList.remove('open'); this.$conversations.focus(); } @@ -890,6 +917,12 @@ export class UI { * @param {{currentId?: string | null}} [options] */ renderConversationList(rows, { currentId = null } = {}) { + if ( + this.$pendingConfirm && + this.$conversationsList.contains(this.$pendingConfirm.$target) + ) { + this._clearConfirm(); + } this.$conversationsList.replaceChildren(); if (rows.length === 0) { const $empty = this.document.createElement('div'); @@ -903,7 +936,8 @@ export class UI { for (const conversation of rows) { const $item = this.$tplConversationItem.content.cloneNode(true).firstElementChild; - $item.dataset.conversationId = conversation.id; + const id = conversation.id; + $item.dataset.conversationId = id; $item.querySelector('.conversations__item-title').textContent = conversationLabel(conversation); $item.querySelector('.conversations__item-created').textContent = @@ -914,10 +948,19 @@ export class UI { `Updated ${CONVERSATION_DATE.format( new Date(conversation.updated_at), )}`; - if (conversation.id === currentId) { + if (id === currentId) { $item.classList.add('conversations__item--current'); $item.setAttribute('aria-current', 'true'); } + $item.querySelector('.conversations__item-main').onclick = () => + this.handlers.onSelectConversation(id); + $item.querySelector('.conversations__item-rename').onclick = () => + this.handlers.onRenameConversation(id); + const $delete = $item.querySelector('.conversations__item-delete'); + $delete.onclick = () => + this._confirm(`conversation:${id}`, $delete, 'pending', () => + this.handlers.onDeleteConversation(id), + ); this.$conversationsList.appendChild($item); } } @@ -1057,9 +1100,21 @@ export class UI { // ==================== _bindEvents() { - // One delegated click listener routes every data-action. - this.document.addEventListener('click', (event) => - this._handleClick(event), + // Clear a pending two-press confirmation when a click lands + // outside its button. Running in the capture phase clears an + // earlier confirmation before the clicked button's own handler + // arms or confirms a new one. + this.document.addEventListener( + 'click', + (event) => { + if ( + this.$pendingConfirm && + !this.$pendingConfirm.$target.contains(event.target) + ) { + this._clearConfirm(); + } + }, + true, ); // Press-and-hold for push-to-talk and transcription. @@ -1103,8 +1158,18 @@ export class UI { this.handlers.onComposeInput(); }); - // Attachments. - this.$attach.addEventListener('click', () => this.$fileInput.click()); + // Compose buttons and attachments. + this.$attach.onclick = () => this.$fileInput.click(); + this.$location.onclick = () => this.handlers.onLocationToggle(); + this.$send.onclick = () => { + if (this.$send.classList.contains('loading')) { + this._confirm('stop', this.$send, 'pending', () => + this.handlers.onStop(), + ); + } else { + this.handlers.onComposeSubmit(); + } + }; this.$fileInput.addEventListener('change', (event) => { this.handlers.onAttachFiles(event.target.files); this.$fileInput.value = ''; @@ -1115,9 +1180,27 @@ export class UI { passive: true, }); + // Toolbar buttons. + this.$settings.onclick = () => this._openSettings(); + this.$conversations.onclick = () => this.openConversations(); + this.$reset.onclick = () => + this._confirm('reset', this.$reset, 'footer__toolbar-btn--pending', () => + this.handlers.onReset(), + ); + this.$scrollUp.onclick = () => { + this.setAutoScroll(false); + this.$chat.scrollTop = 0; + }; + this.$scrollDown.onclick = () => { + // Set scrollTop first, so the scroll listener sees the + // bottom position and does not turn following off again. + this.$chat.scrollTop = this.$chat.scrollHeight; + this.setAutoScroll(true); + }; + this.$mute.onclick = () => this.handlers.onToggleMute(); + // Settings modal. - this.$settings.addEventListener('click', () => this._openSettings()); - this.$settingsClose.addEventListener('click', () => this._closeSettings()); + this.$settingsClose.onclick = () => this._closeSettings(); this.$settingsOverlay.addEventListener('click', (event) => { if (event.target === this.$settingsOverlay) this._closeSettings(); }); @@ -1131,11 +1214,9 @@ export class UI { } }); this.$settingsTabs.forEach(($tab) => { - $tab.addEventListener('click', () => this._switchTab($tab.dataset.tab)); + $tab.onclick = () => this._switchTab($tab.dataset.tab); }); - this.$saveSystemMessage.addEventListener('click', () => - this._saveSystemMessage(), - ); + this.$saveSystemMessage.onclick = () => this._saveSystemMessage(); this.$model.addEventListener('change', () => { const { provider, model } = this.getSelectedModel(); this.handlers.onModelChange({ provider, model }); @@ -1144,12 +1225,8 @@ export class UI { this.handlers.onVoiceChange(this.$voice.value), ); - // Conversations modal. The toolbar button opens it through the - // data-action route; these listeners close it and report the - // search text and the selected row. - this.$conversationsClose.addEventListener('click', () => - this.closeConversations(), - ); + // Conversations modal. + this.$conversationsClose.onclick = () => this.closeConversations(); this.$conversationsOverlay.addEventListener('click', (event) => { if (event.target === this.$conversationsOverlay) { this.closeConversations(); @@ -1167,39 +1244,11 @@ export class UI { this.$conversationsSearch.addEventListener('input', () => this.handlers.onConversationSearch(this.$conversationsSearch.value), ); - this.$conversationNew.addEventListener('click', () => - this.handlers.onNewConversation(), - ); - this.$conversationsList.addEventListener('click', (event) => { - const $item = event.target.closest('[data-conversation-id]'); - if (!$item) return; - const id = $item.dataset.conversationId; - const $rename = event.target.closest('.conversations__item-rename'); - if ($rename) { - this._clearConfirm(); - this.handlers.onRenameConversation(id); - return; - } - const $delete = event.target.closest('.conversations__item-delete'); - if ($delete) { - // Deleting is irreversible: the second press on the same - // button deletes, any other click cancels the request. - this._confirm(`conversation:${id}`, $delete, 'pending', () => - this.handlers.onDeleteConversation(id), - ); - return; - } - this._clearConfirm(); - this.handlers.onSelectConversation(id); - }); + this.$conversationNew.onclick = () => this.handlers.onNewConversation(); // Compose settings modal. - this.$composeSettings.addEventListener('click', () => - this.handlers.onComposeSettings(), - ); - this.$composeSettingsClose.addEventListener('click', () => - this._closeComposeSettings(), - ); + this.$composeSettings.onclick = () => this.handlers.onComposeSettings(); + this.$composeSettingsClose.onclick = () => this._closeComposeSettings(); this.$composeSettingsOverlay.addEventListener('click', (event) => { if (event.target === this.$composeSettingsOverlay) { this._closeComposeSettings(); @@ -1207,143 +1256,6 @@ export class UI { }); } - _handleClick(event) { - const $target = event.target.closest('[data-action]'); - const action = $target?.dataset.action; - - // A click outside any action button cancels every pending - // confirmation. - if (!action) { - this._clearConfirm(); - return; - } - - const $message = $target.closest('.message'); - const messageId = $message?.dataset.messageId || $message?.dataset.id; - - switch (action) { - case 'send': - if (this.$send.classList.contains('loading')) { - this._confirm('stop', $target, 'pending', () => - this.handlers.onStop(), - ); - } else { - this._clearConfirm(); - this.handlers.onComposeSubmit(); - } - break; - case 'reset': - this._confirm('reset', $target, 'footer__toolbar-btn--pending', () => - this.handlers.onReset(), - ); - break; - case 'delete': - // Error bubbles are display-only: remove without a request. - if ( - !$message || - (!$message.dataset.id && !$message.dataset.messageId) - ) { - this._clearConfirm(); - $message?.remove(); - break; - } - this._confirm( - `delete:${messageId}:${$message.dataset.field ?? ''}` + - `:${$message.dataset.toolCallId ?? ''}`, - $target, - 'pending', - () => - this.handlers.onDelete({ - id: messageId, - field: $message.dataset.field || null, - toolCallId: $message.dataset.toolCallId || null, - }), - ); - break; - case 'approve': - case 'deny': { - this._clearConfirm(); - const execution = this.pendingApprovals.find( - ({ messageId: mid, toolCallId }) => - mid === $message?.dataset.parentId && - toolCallId === $message?.dataset.toolCallId, - ); - if (!execution) break; - if (action === 'approve') { - this.handlers.onApprove(execution); - } else { - this.handlers.onDeny(execution); - } - break; - } - case 'edit': - this._clearConfirm(); - this.handlers.onEdit({ - id: messageId, - field: $message?.dataset.field || null, - toolCallId: $message?.dataset.toolCallId || null, - }); - break; - case 'play': - this._clearConfirm(); - if (messageId) this.handlers.onPlay(messageId); - break; - case 'copy': { - this._clearConfirm(); - const text = $message?.dataset.copyText || ''; - this._copyToClipboard($target, text); - break; - } - case 'inspect': { - this._clearConfirm(); - const isOpen = $message.classList.toggle('message--debug-open'); - $target.setAttribute('aria-expanded', String(isOpen)); - break; - } - case 'scroll-up': - this._clearConfirm(); - this.setAutoScroll(false); - this.$chat.scrollTop = 0; - break; - case 'scroll-down': - // Set scrollTop first, so the scroll listener sees the - // bottom position and does not turn following off again. - this._clearConfirm(); - this.$chat.scrollTop = this.$chat.scrollHeight; - this.setAutoScroll(true); - break; - case 'mute': - this._clearConfirm(); - this.handlers.onToggleMute(); - break; - case 'location': - this._clearConfirm(); - this.handlers.onLocationToggle(); - break; - case 'conversations': - this._clearConfirm(); - this.openConversations(); - break; - case 'settings': - this._clearConfirm(); - this._openSettings(); - break; - case 'compose-settings': - this._clearConfirm(); - this.handlers.onComposeSettings(); - break; - case 'attach': - // Handled by the listener on the button itself. - break; - case 'ptt': - case 'transcribe': - // Handled by press-and-hold. - break; - default: - this._clearConfirm(); - } - } - /** * _confirm runs action on the second press of the same control. * The first press marks the control; any other click clears the @@ -1507,15 +1419,13 @@ export class UI { if (event.key === 'Escape') close(null); }; - $modal - .querySelector('.modal__close') - .addEventListener('click', () => close(null)); + $modal.querySelector('.modal__close').onclick = () => close(null); $overlay.addEventListener('click', (event) => { if (event.target === $overlay) close(null); }); this.document.addEventListener('keydown', onKey); - $save.addEventListener('click', () => { + $save.onclick = () => { let value; try { value = readValue($modal); @@ -1538,7 +1448,7 @@ export class UI { return; } close(value); - }); + }; $modal.querySelector('textarea, input')?.focus(); }); @@ -1548,6 +1458,86 @@ export class UI { // PRIVATE: RENDERING // ==================== + /** + * _bindMessageActions attaches click handlers to the action + * buttons present in a cloned message bubble. + * + * Copy reads $bubble.dataset.copyText when clicked so tool bubbles + * copy their updated output after _fillToolOutput runs. Approve + * and deny look up the current execution record in + * this.pendingApprovals when clicked. + * + * @param {HTMLElement} $bubble + * @param {{id?: string | null, field?: string | null, toolCallId?: string | null, isError?: boolean}} [options] + */ + _bindMessageActions( + $bubble, + { id = null, field = null, toolCallId = null, isError = false } = {}, + ) { + const $play = $bubble.querySelector('.message__action-btn--play'); + if ($play) { + $play.onclick = () => { + if (id) this.handlers.onPlay(id); + }; + } + + const $delete = $bubble.querySelector('.message__action-btn--delete'); + if ($delete) { + $delete.onclick = () => { + // Error bubbles are display-only: remove without a request. + if (isError || !id) { + $bubble.remove(); + return; + } + this._confirm( + `delete:${id}:${field ?? ''}:${toolCallId ?? ''}`, + $delete, + 'pending', + () => this.handlers.onDelete({ id, field, toolCallId }), + ); + }; + } + + const $inspect = $bubble.querySelector('.message__action-btn--inspect'); + if ($inspect) { + $inspect.onclick = () => { + const isOpen = $bubble.classList.toggle('message--debug-open'); + $inspect.setAttribute('aria-expanded', String(isOpen)); + }; + } + + const $edit = $bubble.querySelector('.message__action-btn--edit'); + if ($edit) { + $edit.onclick = () => this.handlers.onEdit({ id, field, toolCallId }); + } + + const $copy = $bubble.querySelector('.message__action-btn--copy'); + if ($copy) { + $copy.onclick = () => + this._copyToClipboard($copy, $bubble.dataset.copyText || ''); + } + + const findExecution = () => + this.pendingApprovals.find( + (execution) => + execution.messageId === id && execution.toolCallId === toolCallId, + ); + const $approve = $bubble.querySelector('.message__approval-btn--approve'); + if ($approve) { + $approve.onclick = () => { + const execution = findExecution(); + if (execution) this.handlers.onApprove(execution); + }; + } + const $deny = $bubble.querySelector('.message__approval-btn--deny'); + if ($deny) { + $deny.onclick = () => { + const execution = findExecution(); + if (execution) this.handlers.onDeny(execution); + }; + } + } + _renderUserMessage(row) { const $bubble = this.$tplUserMessage.content.cloneNode(true).firstElementChild; @@ -1557,6 +1547,7 @@ export class UI { $bubble.dataset.copyText = text; this._renderImagePills($bubble, row); this._populateDebug($bubble.querySelector('.message__debug-list'), row); + this._bindMessageActions($bubble, { id: row.id }); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } @@ -1588,6 +1579,7 @@ export class UI { $bubble.dataset.voice = row.voice || ''; $bubble.dataset.copyText = text; this._populateDebug($bubble.querySelector('.message__debug-list'), row); + this._bindMessageActions($bubble, { id: row.id }); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } @@ -1603,6 +1595,7 @@ export class UI { $bubble.querySelector('.message__content').textContent = reasoning; $bubble.dataset.copyText = reasoning; this._populateDebug($bubble.querySelector('.message__debug-list'), row); + this._bindMessageActions($bubble, { id: row.id, field: 'reasoning' }); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } @@ -1651,6 +1644,11 @@ export class UI { ...result, voice: parentRow?.voice, }); + this._bindMessageActions($bubble, { + id: parentRow?.id ?? null, + field: parentRow ? 'tool' : null, + toolCallId: callId || null, + }); this.$chat.insertBefore($bubble, this.$chatLoading); this._applyApproval($bubble); this.scrollToBottom(); @@ -1689,6 +1687,7 @@ export class UI { const text = extractText(row.content?.content); $bubble.querySelector('.message__content').textContent = text; $bubble.dataset.copyText = text; + this._bindMessageActions($bubble, { id: row.id }); this.$chat.insertBefore($bubble, this.$chatLoading); } diff --git a/internal/service/templates/static/footer/compose.gohtml b/internal/service/templates/static/footer/compose.gohtml index 1aad779..6bd5e5e 100644 --- a/internal/service/templates/static/footer/compose.gohtml +++ b/internal/service/templates/static/footer/compose.gohtml @@ -14,7 +14,6 @@ type="button" class="compose__action-btn" id="compose-settings" - data-action="compose-settings" aria-label="Compose settings" > @@ -23,7 +22,6 @@ type="button" class="compose__action-btn" id="attach" - data-action="attach" aria-label="Attach files" > @@ -32,7 +30,6 @@ type="button" class="compose__action-btn" id="location" - data-action="location" aria-label="Include location" title="Include GPS location" > @@ -64,7 +61,6 @@ type="button" class="compose__action-btn compose__action-btn--send" id="send" - data-action="send" aria-label="Send message" > diff --git a/internal/service/templates/static/footer/toolbar.gohtml b/internal/service/templates/static/footer/toolbar.gohtml index 7f67515..71fe1ad 100644 --- a/internal/service/templates/static/footer/toolbar.gohtml +++ b/internal/service/templates/static/footer/toolbar.gohtml @@ -4,7 +4,6 @@ type="button" class="footer__toolbar-btn" id="settings" - data-action="settings" aria-label="Settings" > @@ -13,7 +12,6 @@ type="button" class="footer__toolbar-btn" id="conversations" - data-action="conversations" aria-label="Conversations" > @@ -24,7 +22,6 @@ type="button" class="footer__toolbar-btn" id="reset" - data-action="reset" aria-label="Reset conversation" > @@ -33,7 +30,6 @@ type="button" class="footer__toolbar-btn" id="scroll-up" - data-action="scroll-up" aria-label="Scroll to top" > @@ -42,7 +38,6 @@ type="button" class="footer__toolbar-btn" id="scroll-down" - data-action="scroll-down" aria-label="Scroll to bottom and follow" > @@ -51,7 +46,6 @@ type="button" class="footer__toolbar-btn" id="mute" - data-action="mute" aria-label="Mute" > diff --git a/internal/service/templates/static/templates.gohtml b/internal/service/templates/static/templates.gohtml index 97874c9..5c992ae 100644 --- a/internal/service/templates/static/templates.gohtml +++ b/internal/service/templates/static/templates.gohtml @@ -9,15 +9,13 @@ @@ -257,15 +240,13 @@