From 40a054b99b09bf15e044885e2bc92b1746c8c674 Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Tue, 8 Sep 2026 05:25:32 +0800 Subject: [PATCH] fix(client): guard the DNA bar against a stale hover key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A snapshot push can drop the band under a resting pointer (compaction, tail slide out of the served window) without a mouseleave, and the dead key exact-matched nothing: the whole strip sat dimmed with nothing lit — the very state the open-category pin's breakdown gate rules out. Honor a band hover only while its key names a rendered band, so the mirror/pin fallbacks resume underneath. Also correct the hover-link comment: DNA hovers stay on the bar and never report back to the overview (the link is one-way in DNA mode, as the existing test already pins). --- src/client/components/browser.tsx | 15 +++++++++----- tests/client/components/browser.spec.ts | 26 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index af4a838..e390c92 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -746,6 +746,10 @@ export function makeContextBrowser( label: dnaLabel(it), group: it.cat, })) ?? null + // A band hover is honored only while its key names a RENDERED band: a push can drop the band under a resting + // pointer (compaction, tail slide) without a mouseleave, and a dead key exact-matches nothing — the bar would + // sit all-dimmed with nothing lit (the same invariant the pin's `breakdown` gate keeps). + const liveDnaKey = dnaKey !== null && dnaByKey.has(dnaKey) ? dnaKey : null // A band click opens its category + element row below (the same reveal the step brief uses) and scrolls it into view. const pickDna = (key: string): void => { const it = dnaByKey.get(key) @@ -1051,11 +1055,12 @@ export function makeContextBrowser( parts={dnaParts ?? parts} height={10} // Highlight precedence: pointer hover (local in DNA mode, the shared link otherwise) over the open-category pin. - // The mirrored hover link (see `linked` above) works while the browser shows the live surface — in DNA mode an incoming - // category key lights that category's BANDS (the parts' `group`) — and reports hovers back to the overview. Tip stays off - // in category mode: a cross-card hover must not float a second tooltip over a bar the pointer does not rest on — and the - // group-keyed pin never exact-matches a DNA band, so the pinned highlight floats no tooltip either. - hoverKey={dna ? dnaKey ?? linkKey ?? pinKey : linkKey ?? pinKey} + // The mirrored link (see `linked` above) works while the browser shows the live surface; it is ONE-WAY in DNA + // mode — an incoming category key lights that category's BANDS (the parts' `group`), while band hovers stay + // on the bar and never report upward (the overview keeps showing the live category composition). Tip stays + // off in category mode: a cross-card hover must not float a second tooltip over a bar the pointer does not + // rest on — and the group-keyed pin never exact-matches a DNA band, so the pinned highlight floats no tip. + hoverKey={dna ? liveDnaKey ?? linkKey ?? pinKey : linkKey ?? pinKey} onHoverKey={dna ? setDnaKey : linked ? props.onHoverKey : undefined} tip={dna} onPickKey={dna ? pickDna : undefined} diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index 69987f9..b3a4330 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -1754,6 +1754,32 @@ describe('ContextBrowser DNA mode and the open-category bar pin', () => { await m.unmount() }) + test('a push dropping the hovered band does not dim the bar with nothing lit; the pin resumes underneath', async () => { + const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders }))) + await click(dnaButton(m)) + await hover(bands(m)[3]) + assert.ok(bands(m)[3].className.includes('lc-stacked-seg-on')) + assert.ok(query(m.container, '.lc-stacked').className.includes('lc-stacked-dim')) + // The hovered node is compacted out of the live surface while the pointer rests on its band — a removed element + // fires no mouseleave, so the stale key must be dropped instead of dimming the whole bar with nothing lit. + const pushed = tl({ + current: { ...dnaData.current, user: 0, total: 255 }, + requests: dnaData.requests, + nodes: dnaData.nodes.filter(n => n.seq !== 2), + }) + await m.update(h(Browser, props({ data: pushed, headers: dnaHeaders }))) + assert.ok(!query(m.container, '.lc-stacked').className.includes('lc-stacked-dim')) + assert.ok(bands(m).every(seg => !seg.className.includes('lc-stacked-seg-on'))) + assert.ok(!query(m.container, '.lc-bar-tip').className.includes('lc-bar-tip-on'), 'the dead key floats no tooltip either') + // Band order is now sys, bash, write, n3, n4, n5: the open-category pin still lights its group under the stale hover. + await click(catRow(m, 'assistant')) + assert.deepEqual(bands(m).map(seg => seg.className.includes('lc-stacked-seg-on')), [false, false, false, false, true, false]) + // And a fresh hover on a rendered band still wins over the pin. + await hover(bands(m)[3]) + assert.deepEqual(bands(m).map(seg => seg.className.includes('lc-stacked-seg-on')), [false, false, false, true, false, false]) + await m.unmount() + }) + test('the pin drops while the previewed step holds nothing for the open category', async () => { const wired = (previewSeq: number | null) => h(Browser, props({ data: dnaData, headers: dnaHeaders, previewSeq, onHoverKey: () => {} })) const m = await mount(wired(null))