fix(split-pane): stop leaking document listeners on repeated split-picker toggles

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
timkjr
2026-09-20 13:10:29 -05:00
co-authored by Claude Sonnet 5
parent d0a887d98a
commit 33b2605815
3 changed files with 299 additions and 7 deletions
+1 -1
View File
@@ -191,7 +191,7 @@
</button>
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker()" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
+36 -6
View File
@@ -124,7 +124,14 @@
})(window);
Object.assign(CodemanApp.prototype, {
openSplitPicker() {
openSplitPicker(event) {
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
// OPENING click so it never reaches the outside-click listener this
// same call is about to register — without it, a click landing on the
// button's own inner <svg> (matched by neither `menu.contains()` nor
// the old exact-node check below) bubbled straight through to
// `document` and self-closed the menu it just opened.
event?.stopPropagation();
if (this._splitPane) {
this.closeSplitPane();
return;
@@ -134,8 +141,13 @@ Object.assign(CodemanApp.prototype, {
this.sessionOrder,
this.activeSessionId
);
const existing = document.getElementById('splitPickerMenu');
if (existing) existing.remove();
// Route a pre-existing menu through the SAME dismiss path used
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
// menu has live document listeners (see below), and a raw removal left
// them attached forever — only the single-slot field below got
// overwritten, so every prior pair but the last was orphaned on
// `document` with no way to ever find and remove it again.
this._dismissSplitPicker();
const menu = document.createElement('div');
menu.id = 'splitPickerMenu';
@@ -162,14 +174,32 @@ Object.assign(CodemanApp.prototype, {
// Dismiss on outside click or Escape — same one-shot listener pattern as
// session-ui.js's other transient popovers (toggleCaseSettings(),
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
// menu (still bubbling) doesn't immediately close it. Picking an item
// menu (still bubbling) doesn't immediately close it — reinforced by
// the button's own stopPropagation() above, which is what actually
// stops that same click reaching `document` at all. Picking an item
// (above) calls the SAME dismiss method, so these listeners never
// outlive the menu either way.
//
// Self-removing by identity: each handler removes ITSELF (and its
// sibling) the moment it fires, rather than leaning solely on the
// `this._splitPickerDismissHandlers` field. That field is still kept in
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
// item's onclick above, or a still-open menu at the top of this method
// — can find and remove the CURRENT pair), but no path here can ever
// again leave a pair attached to `document` with nothing referencing it.
const closeOnOutsideClick = (e) => {
if (!menu.contains(e.target) && e.target !== splitBtn) this._dismissSplitPicker();
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
document.removeEventListener('click', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
this._splitPickerDismissHandlers = null;
menu.remove();
};
const closeOnEscape = (e) => {
if (e.key === 'Escape') this._dismissSplitPicker();
if (e.key !== 'Escape') return;
document.removeEventListener('click', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
this._splitPickerDismissHandlers = null;
menu.remove();
};
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);