diff --git a/src/web/public/app.js b/src/web/public/app.js index 24b7be3e..584007a5 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5785,8 +5785,10 @@ class CodemanApp { * * A group or case box left with nothing showing hides with its header, its * count shows the rows left showing (a kept row included), and the grouped - * tree's roving stop and posinset follow the visible items. A collapsed group's rows are not in the DOM at all, - * which is why the rail search also expands the projection (_projectTabGroups). + * tree's roving stop and posinset follow the visible items. A collapsed + * group's rows are not in the DOM at all, which is why the rail search also + * expands the projection (_projectTabGroups). Rows that appear or disappear + * move the rows below them, so the connector lines are redrawn then. */ _applyTabListFilter() { const container = this.$('sessionTabs'); @@ -5806,13 +5808,23 @@ class CodemanApp { })); const result = window.CodemanTabSearch?.filter(rows, query); if (!result) return; + // Whether anything appeared or disappeared: the rows below it then moved. + let moved = false; // State headings count the whole group, so they step aside while a filter // is narrowing the rows under them (styles.css, .tabs-filtering). - container.classList.toggle('tabs-filtering', result.active); - for (const row of rows) row.key.classList.toggle('tab-filtered-out', result.hidden.has(row.key)); + if (container.classList.contains('tabs-filtering') !== result.active) { + container.classList.toggle('tabs-filtering', result.active); + moved = true; + } + const setFilteredOut = (el, out) => { + if (el.classList.contains('tab-filtered-out') === out) return; + el.classList.toggle('tab-filtered-out', out); + moved = true; + }; + for (const row of rows) setFilteredOut(row.key, result.hidden.has(row.key)); for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) { const shown = result.counts.get(section) ?? 0; - section.classList.toggle('tab-filtered-out', result.active && shown === 0); + setFilteredOut(section, result.active && shown === 0); const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count'); if (!count) continue; if (count.dataset.total === undefined) count.dataset.total = count.textContent; @@ -5820,8 +5832,21 @@ class CodemanApp { if (count.textContent !== text) count.textContent = text; } const empty = document.getElementById('tabRailSearchEmpty'); - if (empty) empty.hidden = !(rail && result.active && result.matchCount === 0); - if (container.getAttribute('role') === 'tree') { + const emptyHidden = !(rail && result.active && result.matchCount === 0); + if (empty && empty.hidden !== emptyHidden) { + empty.hidden = emptyHidden; + moved = true; + } + // Lineage and subagent/ultracode connectors are anchored to row positions. + // A render redraws them itself, but a keystroke in either box only toggles + // classes here, so the rows it moved would leave the lines pointing at where + // they were. Only when something moved: an unchanged re-apply at every + // render tail stays free, and the call coalesces with a render's own. + if (moved) this.updateConnectionLines?.(); + // Both render paths already set posinset and the roving stop over an + // unfiltered tree, so this second pass only runs while a search hides + // something or right after one changed what shows. + if ((moved || result.active) && container.getAttribute('role') === 'tree') { const items = this._applyTabTreePositions(container); const stop = container.querySelector('[role="treeitem"][tabindex="0"]'); if (items.length && !items.includes(stop)) { diff --git a/test/tab-rail-search.browser.test.ts b/test/tab-rail-search.browser.test.ts index 436a160e..f2ee4380 100644 --- a/test/tab-rail-search.browser.test.ts +++ b/test/tab-rail-search.browser.test.ts @@ -103,7 +103,9 @@ describe('vertical rail session search in Chromium', () => { app.renderSubagentTabBadge = () => ''; app.cancelHideSubagentDropdown = () => undefined; app.updateTabOverflowMode = () => undefined; - app.updateConnectionLines = () => undefined; + app.updateConnectionLines = () => { + w.__redraws++; + }; app._applyTabEntrances = () => undefined; app._scrollActiveTabIntoView = () => undefined; app.isSessionSidebarActive = () => false; @@ -153,6 +155,7 @@ describe('vertical rail session search in Chromium', () => { app._fullRenderSessionTabs(); w.__activation = null; w.__closed = []; + w.__redraws = 0; }, LAYOUT); }); @@ -225,6 +228,25 @@ describe('vertical rail session search in Chromium', () => { expect(await page.evaluate(() => (window as any).__closed)).toEqual(['panels', 'help', 'session-manager']); }); + it('redraws the connector lines when a keystroke moves the rows', async () => { + const search = page.getByRole('searchbox', { name: 'Search sessions' }); + const notesTop = () => + page.evaluate(() => document.querySelector('#sessionTabs [data-id="notes"]')!.getBoundingClientRect().top); + // The first keystroke opens the collapsed Planning group, a render that + // redraws on its own. The next one only toggles classes. + await search.fill('e'); + expect(await paintedRows()).toEqual(['review', 'notes']); + const before = await notesTop(); + await page.evaluate(() => { + (window as any).__redraws = 0; + }); + + await search.fill('es'); + expect(await paintedRows()).toEqual(['notes']); + expect(await notesTop()).toBeLessThan(before); + expect(await page.evaluate(() => (window as any).__redraws)).toBe(1); + }); + it('walks only the matches with the arrow keys', async () => { await page.getByRole('searchbox', { name: 'Search sessions' }).fill('a'); // 'a' matches Alpha API, Roadmap, API Review and Dashboard, not Notes. diff --git a/test/tab-rail-search.test.ts b/test/tab-rail-search.test.ts index b5c9ccec..cf42a78d 100644 --- a/test/tab-rail-search.test.ts +++ b/test/tab-rail-search.test.ts @@ -606,6 +606,65 @@ describe('Escape in the search box', () => { }); }); +describe('connector lines follow the rows a search moves', () => { + it('redraws on a rail keystroke that hides or reveals rows, and only then', () => { + const app = makeApp({ tabLayout: null }); + app._fullRenderSessionTabs(); + const redraw = vi.fn(); + app.updateConnectionLines = redraw; + const full = vi.spyOn(app, '_fullRenderSessionTabs'); + + app.setTabRailSearch('road'); + // The keystroke path: no render, which would have redrawn them itself. + expect(full).not.toHaveBeenCalled(); + expect(redraw).toHaveBeenCalledTimes(1); + + // Same rows showing: nothing moved, nothing to redraw. + app.setTabRailSearch('roa'); + expect(redraw).toHaveBeenCalledTimes(1); + + app.setTabRailSearch(''); + expect(redraw).toHaveBeenCalledTimes(2); + // The re-apply every render tail runs costs nothing when nothing changed. + app._applyTabListFilter(); + expect(redraw).toHaveBeenCalledTimes(2); + }); + + it('redraws on a grouped rail when a group empties, and when the empty note shows', () => { + const app = makeApp(); + app._fullRenderSessionTabs(); + const redraw = vi.fn(); + app.updateConnectionLines = redraw; + const full = vi.spyOn(app, '_fullRenderSessionTabs'); + + app.setTabRailSearch('notes'); + expect(full).not.toHaveBeenCalled(); + // Only the Ungrouped section (no group id) is left showing. + expect(visibleGroups()).toEqual(['']); + expect(redraw).toHaveBeenCalledTimes(1); + + // Nothing matches: the rows were already gone, but the note pushes the list down. + app.setTabRailSearch('notesz'); + expect(emptyNote().hidden).toBe(false); + expect(redraw).toHaveBeenCalledTimes(2); + }); + + it('redraws on a sidebar filter keystroke too', () => { + const app = makeApp({ tabLayout: null }); + document.documentElement.setAttribute('data-tab-orientation', 'horizontal'); + app.isSessionSidebarActive = () => true; + app._fullRenderSessionTabs(); + const redraw = vi.fn(); + app.updateConnectionLines = redraw; + + app.applySidebarFilter('/srv/api'); + expect(visibleRows()).toEqual(['roadmap']); + expect(redraw).toHaveBeenCalledTimes(1); + app.applySidebarFilter('/srv/api'); + expect(redraw).toHaveBeenCalledTimes(1); + }); +}); + // ─── Markup + zh-CN ─────────────────────────────────────────────────────────── describe('markup and zh-CN', () => {