From d7f604752986140f14c099cd0fc737b750567510 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 06:57:54 +0200 Subject: [PATCH] fix(tiles): a tile focus no longer leaves a glow listener on its tab _selectTiledSession added a once animationend listener to the focused tile's tab on every focus. On every skin but OG the glow is `animation: none`, so animationend never fires: the listeners piled up on the tab (and the class stayed). It now glows a tab only when it is not glowing already, so a tab holds at most one; on OG the animation ends, the class goes and the next focus glows again. Measured (50 tile focus changes, daylight-blue): animationend listeners on the tabs 0 -> 49 before, 0 -> 6 (one per tab) after. Over the leak run's 20 grid open/close cycles the page's listener count grew 1003 -> 1042; this is the part CDP could attribute. Live, this code: 50 tile focus changes leave 6 animationend listeners on the tabs, one per tab. The single view's copy of the same block (app.js selectSession) has the same leak on every non-OG skin; it is a separate, pre-existing copy and is left alone here. Test: ten focus changes leave one listener; after animationend the next focus glows again; mutation-checked. Scope: PR 2 (tile-grid.js). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 5 ++++- test/tile-grid-select-branch.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 74c457ac..c8c04f85 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -1535,7 +1535,10 @@ Object.assign(CodemanApp.prototype, { this.closeSessionSidebarOnHandheld?.(); this.renderSessionTabs?.(); const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`); - if (activeTab) { + // Not while it still glows: on every skin but OG the glow is `animation: + // none`, so animationend never comes and each tile focus used to leave + // another listener behind on the tab (49 after 50 focus changes). + if (activeTab && !activeTab.classList.contains('tab-glow')) { activeTab.classList.add('tab-glow'); activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true }); } diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index 3599f965..b52b1a91 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -19,7 +19,9 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + FakeEl, FakeTile, + bySelector, idleCallbacks, localStore, makeGridApp, @@ -63,6 +65,29 @@ describe('selectSession with the grid open', () => { expect(app._focusedPane().sessionId).toBe('s-b'); }); + it('focus changes leave at most one glow listener on a tab, and the glow runs again once it ended', async () => { + // On every skin but OG the glow is `animation: none`: animationend never + // fires, and every focus used to add one more once-listener to the tab. + const app = openGrid(); + const tab = new FakeEl(); + tab.className = 'session-tab active'; + bySelector.set('.session-tab.active[data-id="s-b"]', tab); + for (let i = 0; i < 5; i++) { + await app.selectSession('s-b'); + await app.selectSession('s-a'); + } + expect(tab.classList.contains('tab-glow')).toBe(true); + expect(tab.listeners.animationend).toHaveLength(1); + + // OG: the animation ends, the class goes, and the next focus glows again. + tab.dispatch('animationend'); + tab.listeners.animationend = []; + expect(tab.classList.contains('tab-glow')).toBe(false); + await app.selectSession('s-b'); + expect(tab.classList.contains('tab-glow')).toBe(true); + expect(tab.listeners.animationend).toHaveLength(1); + }); + it('a user-initiated focus acknowledges the idle alert; an `auto` one does not', async () => { const app = openGrid(); await app.selectSession('s-b');