feat(browser): link the DNA toggle with the trend card — one switch, both cards

The DNA on/off state lifts no further than it already sat: the Context
tab's `dna` state now feeds the Context browser through a controlled
pair of props (dna + onDnaChange), so toggling either card's switch
moves the other — the trend bars and the browser's composition bar
become fingerprints together, and drop back together.

The browser stays self-contained where no parent links it: the pair is
controlled only when BOTH props arrive, a lone `dna` prop is ignored,
and the /context modal keeps its mount-local toggle (the established
optional-seam idiom).
This commit is contained in:
bowenliang123 committed 2026-09-30 01:33:46 +08:00
1 parent 8f2fcf7903
commit dc11a0bb9c
4 files changed
+69 -9

No files matched your search

+22 -3
View File
@@ -47,6 +47,13 @@ export interface ContextBrowserProps {
previewSeq?: number | null
/** Pin-seq: a pin selects that step; pinSeq null returns the browser to the live surface. */
pinSeq?: number | null
/**
* DNA mode's on/off, LINKED across cards: the Context tab passes its trend card's toggle state
* so both DNA switches move as one. Controlled only when BOTH props arrive; absent (the
* /context modal) — the browser keeps its own mount-local toggle.
*/
dna?: boolean
onDnaChange?: (on: boolean) => void
/**
* One-shot reveal request (the step brief's locate, a trend-DNA band pick): select the step, open the
* item's category and element, scroll it into view. `key` is the band key both DNA surfaces share —
@@ -681,8 +688,20 @@ export function makeContextBrowser(
// Mount-time default from the plugin settings card; in-toolbar toggling
// stays mount-local and never writes back.
const [toolSort, setToolSort] = useState<DefaultToolSort>(() => settings.defaultToolSort())
// DNA mode: the composition bar redraws as ONE band per context item in prompt order (dna.ts), hovered/clicked per item.
const [dna, setDna] = useState(false)
// DNA mode: the composition bar redraws as ONE band per context item in prompt order (dna.ts), hovered/clicked per
// item. Parent-linked when both props arrive — the Context tab's trend toggle moves this one too; a lone
// prop is ignored — otherwise mount-local (the /context modal).
const [dnaLocal, setDnaLocal] = useState(false)
const dnaLinked = props.dna !== undefined && props.onDnaChange !== undefined
const dna = dnaLinked ? props.dna === true : dnaLocal
const setDna = (on: boolean): void => {
if (dnaLinked) {
/* v8 ignore next 1 -- `dnaLinked` requires both props, so the handler is always present. */
props.onDnaChange?.(on)
} else {
setDnaLocal(on)
}
}
const [dnaKey, setDnaKey] = useState<string | null>(null)
// δ baseline toggle: 'step' diffs against the immediately preceding record, 'turn' against the
// previous turn's last step. Mount default from the plugin settings card; in-toolbar toggling
@@ -1229,7 +1248,7 @@ export function makeContextBrowser(
<button
type="button"
className={'lc-gran-btn' + (dna ? ' lc-gran-on' : '')}
onClick={() => { setDna(on => !on) }}
onClick={() => { setDna(!dna) }}
>
{t('browser.dna')}
</button>
+6 -2
View File
@@ -111,8 +111,9 @@ export function makeContextView(
// like granularity, the default is read at mount and in-chart toggling never writes back.
const [trendMode, setTrendMode] = useState<'total' | 'delta'>(() => settings.defaultTrendMode())
// DNA mode: the trend bars become per-item fingerprints of each request's context (dna.ts); like the
// toggles above, mount-local and never written back. Orthogonal to the Total/Delta switch — delta
// diffs the bands against the previous bar, so the two combine into a per-item change view.
// toggles above, mount-local and never written back — and SHARED with the Context browser, whose DNA
// toggle rides this same state so both switches move as one. Orthogonal to the Total/Delta switch —
// delta diffs the bands against the previous bar, so the two combine into a per-item change view.
const [dna, setDna] = useState(false)
// Adaptive scale (the title-adjacent toggle): the trend bars rescale to the visible window; like the two
// toggles above, mount-local and never written back.
@@ -566,6 +567,9 @@ export function makeContextView(
loadImage={loadImage}
detailState={source.detailState}
onDetailRetry={source.retryDetail}
// The DNA switch is shared with the trend card: both toggles move as one.
dna={dna}
onDnaChange={setDna}
/>
)
+21 -2
View File
@@ -1987,8 +1987,27 @@ describe('ContextBrowser DNA mode and the open-category bar pin', () => {
return queryAll(m.container, '.lc-stacked-seg')
}
test('the toggle redraws the composition bar as one band per item, in prompt order', async () => {
const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders })))
test('the DNA toggle is parent-linked when both props arrive; a lone prop is ignored', async () => {
// Linked on: the bar redraws and the button reflects the parent's state without a click.
const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders, dna: true, onDnaChange: () => {} })))
assert.equal(bands(m).length, 7)
assert.ok(dnaButton(m).className.includes('lc-gran-on'))
// A lone `dna` prop without the handler does NOT link: the mount-local toggle still rules.
await m.update(h(Browser, props({ data: dnaData, headers: dnaHeaders, dna: true })))
assert.equal(bands(m).length, 6, 'unlinked: the local toggle is off')
// The click reports to the parent instead of flipping local state; the bar follows the prop.
const calls: boolean[] = []
await m.update(h(Browser, props({
data: dnaData, headers: dnaHeaders, dna: true,
onDnaChange: (on) => { calls.push(on) },
})))
await click(dnaButton(m))
assert.deepEqual(calls, [false])
assert.equal(bands(m).length, 7, 'still on until the parent re-renders the prop')
await m.unmount()
})
test('the toggle redraws the composition bar as one band per item, in prompt order', async () => { const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders })))
// Category mode: one segment per category in CATS order, pick cursor off, tooltip slot unmounted.
assert.equal(bands(m).length, 6)
const normal = bands(m).map(seg => seg.style.width)
+20 -2
View File
@@ -532,8 +532,26 @@ describe('ContextView — interactions', () => {
await m.unmount()
})
test('clicking a DNA band reveals that item in the Context browser', async () => {
const m = await mountRich('sv-dna-reveal')
test('the trend and browser DNA toggles move as one', async () => {
const m = await mountRich('sv-dna-link')
const trendDna = () => query(m.container, '.lc-trend-dna .lc-gran-btn')
const browserDna = () => query(m.container, '.lc-br-dna-ctl .lc-gran-btn')
assert.ok(!trendDna().className.includes('lc-gran-on') && !browserDna().className.includes('lc-gran-on'), 'both off at mount')
// The browser follows the trend toggle — its composition bar redraws as a fingerprint too.
await click(trendDna())
assert.ok(browserDna().className.includes('lc-gran-on'), 'the browser follows the trend toggle')
assert.equal(queryAll(m.container, '.lc-br-bar-dna').length, 1)
// And the trend follows the browser toggle — the chart's strips drop away.
await click(browserDna())
assert.ok(!trendDna().className.includes('lc-gran-on'), 'the trend follows the browser toggle')
assert.equal(queryAll(m.container, '.lc-bar-dna').length, 0)
assert.equal(queryAll(m.container, '.lc-br-bar-dna').length, 0)
await m.unmount()
})
test('clicking a DNA band reveals that item in the Context browser', async () => { const m = await mountRich('sv-dna-reveal')
await click(buttonByText(m.container, DICT_EN['trend.dna']))
// Give the strip a real 112px stack area, then click its very top: the hit is the LAST band of
// bar seq 4 — the 'file output' tool result (system and the header lead the strip below it).