fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684)

The app's ad-hoc dropdown/context menus each wire their own document-level
outside-click listener, but that listener only removes itself on an *outside*
click. Every other dismissal path -- clicking a menu item (which calls
el.remove() directly), a Cancel button, Escape, or the "close the
previously-open menu" reopen sweep -- tears the node down without
unregistering the listener, orphaning it on `document`. The stranded listener
then lingers and can break the next menu interaction: the recurring "the
button stops working until I refresh the page" class of bug (e.g. delete an
email, then the kebab/more button is dead on the other rows).

Route all 16 of these menus through the existing escMenuStack helper
(bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js
_showLibDropdown, cookbookRunning.js, and research/panel.js already do: a
single idempotent close() owns the teardown and is released on every dismissal
path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and
Escape flows through the central LIFO esc-stack arbiter. Net -49 lines.

Menus migrated: cookbook _showDepMenu; document export menu and
_openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary
_showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery
_showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings
(3 unified-integrations dropdowns); skills _openSkillMenu; tasks
_showTaskDropdown; compare _toggleExportMenu.

Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms
ghost-click guard, emailLibrary's reader-more-active anchor class and the
bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles).

Six menus with custom lifecycles (notes _openReminderMenu, sessions
long-press, document markdown-toolbar, emojiPicker, compare model selector)
are intentionally left for a follow-up -- each needs individual review.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mocchibird
2026-06-22 20:40:56 +02:00
committed by GitHub
parent b899095f18
commit 4c82e4a172
10 changed files with 106 additions and 155 deletions
+6 -6
View File
@@ -7,6 +7,7 @@
import uiModule from './ui.js';
import * as spinnerModule from './spinner.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API = window.location.origin;
let skills = [];
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
// Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete).
function _openSkillMenu(btn, card, sk, name, isPublished) {
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
const menu = document.createElement('div');
menu.className = 'skill-kebab-menu';
const mk = (paths, label, opts, onClick) => {
const item = document.createElement('button');
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
menu.appendChild(item);
};
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
@@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
selItem.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
close();
if (!_selectMode) _enterSelectMode();
_selectedNames.add(name);
renderSkillsList();
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
const cancelItem = document.createElement('button');
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
menu.appendChild(cancelItem);
document.body.appendChild(menu);
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
menu.style.overflowY = 'auto';
}
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
setTimeout(() => document.addEventListener('click', close, true), 0);
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
}
// Cards for the agent's built-in tool capabilities (from