feat(client): echo a DNA band hover onto its feed row

Hovering a band on the DNA bar now lights the row it names below — a message
band its exact feed row, a tool-schema band the tools group row, the system
band the system row — the reverse of the row-hover-to-band link, so the two
surfaces read as one surface. The echoed row also scrolls into view, confined
to the nearest scrollable ancestor's own scrollport (the capped column): the
row only has to reach the scrollport's visible slice, since a capped column
usually extends below the page fold and a page scroll would drag the pointer
off the pinned band mid-scrub. Clicking a band still lands on the row and
expands it.
This commit is contained in:
bowenliang123 committed 2026-10-16 22:33:19 +08:00
1 parent ef478dee24
commit 9217fca879
3 files changed
+52 -2

No files matched your search

+32 -2
View File
@@ -325,6 +325,32 @@ export function makeContextBrowser(
&& (rowDnaKey === 'tools' ? view.header !== null
: rowDnaKey === 'system' ? view.system !== null
: dnaByKey.has(rowDnaKey)) ? rowDnaKey : null
// A band hover brings its echoed row into view — within the nearest scrollable ancestor's own scrollport
// (the capped column) and nothing above it: the pointer rests on the pinned bar, and a page scroll would
// drag it off the band mid-scrub.
useLayoutEffect(() => {
if (liveDnaKey === null) return
/* v8 ignore next 1 -- liveDnaKey non-null implies a mounted card: the ref always resolves. */
const row = rootRef.current?.querySelector('.lc-br-elem-echo') ?? null
/* v8 ignore next 1 -- a live band names a rendered item, so its echoed row always exists. */
if (row === null) return
let scroller: HTMLElement | null = row.parentElement
while (scroller !== null && scroller.scrollHeight <= scroller.clientHeight) {
scroller = scroller.parentElement
}
/* v8 ignore next 1 -- jsdom lays out nothing: no ancestor ever overflows. */
if (scroller === null) return
/* v8 ignore start -- real layout only: jsdom measures no boxes. */
const rb = row.getBoundingClientRect()
const sb = scroller.getBoundingClientRect()
// The row only has to reach the scrollport's VISIBLE slice: a capped column usually extends below the
// page fold, so its own box bottom is no visibility boundary.
const visTop = Math.max(sb.top, 0)
const visBottom = Math.min(sb.bottom, window.innerHeight)
if (rb.top < visTop) scroller.scrollTop -= visTop - rb.top
else if (rb.bottom > visBottom) scroller.scrollTop += rb.bottom - visBottom
/* v8 ignore stop */
}, [liveDnaKey])
const pickDna = (key: string): void => {
const it = dnaByKey.get(key)
/* v8 ignore next 1 -- the bar only reports keys of the parts it was handed; defensive. */
@@ -407,7 +433,9 @@ export function makeContextBrowser(
/** Expandable element row; `err` carries the red run-state dot right after the chevron so a failed result scans while collapsed.
* `opts.open` overrides the `openElem === key` derivation for rows whose key set is wider (the flat feed's tools group);
* `opts.onHover` lets a DNA feed row light its own band on the composition bar. */
* `opts.onHover` lets a DNA feed row light its own band on the composition bar. The reverse echo — a DNA band
* hover lighting its row — derives straight from the band key: a node or the system prompt matches its own row,
* a tool-schema band matches the tools group row. */
const elemRow = (
key: string, tag: ReactNode | null, preview: string,
tokens: number, time: number | undefined, body: ReactNode,
@@ -417,8 +445,10 @@ export function makeContextBrowser(
) => {
const open = opts?.open ?? openElem === key
const hover = opts?.onHover
const echo = dna && liveDnaKey !== null
&& (liveDnaKey === key || (key === 'tools' && liveDnaKey.startsWith('tool:')))
return (
<div key={key} className={'lc-br-elem' + (open ? ' lc-br-elem-on' : '')}>
<div key={key} className={'lc-br-elem' + (open ? ' lc-br-elem-on' : '') + (echo ? ' lc-br-elem-echo' : '')}>
<button
type="button"
className="lc-br-elem-row hover:bg-(--dsw-alias-interactive-bg-hover)"
+2
View File
@@ -88,6 +88,8 @@
.lc-br-time { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; }
/* A PENDING stamp (a live-tail item no request carries yet) is an estimate; its tooltip names the not-yet-sent step. */
.lc-br-time-pending { text-decoration: underline dotted; text-underline-offset: 2px; }
/* A DNA band hover echoes onto its feed row: the row's button wears the hover wash while the pointer is on the bar. */
.lc-br-elem-echo > .lc-br-elem-row { background-color: var(--dsw-alias-interactive-bg-hover); }
/* Call-hit tally on a tool-definition row. */
.lc-br-hits { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.lc-br-content { padding: 2px 6px 8px 26px; display: flex; flex-direction: column; gap: 6px; }
@@ -326,6 +326,24 @@ describe('ContextBrowser DNA mode and the open-category bar pin', () => {
await m.unmount()
})
test('hovering a DNA band echoes onto its feed row; a tool-schema band lights the tools group row', async () => {
const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders })))
await click(dnaButton(m))
// Band n3 (index 4) is feed row index 2 (the feed runs newest-first).
await hover(bands(m)[4])
assert.ok(flatRows(m)[2].className.includes('lc-br-elem-echo'))
assert.ok(flatRows(m).every((r, i) => i === 2 || !r.className.includes('lc-br-elem-echo')))
await unhover(bands(m)[4])
assert.ok(flatRows(m).every(r => !r.className.includes('lc-br-elem-echo')))
// A tool-schema band echoes onto the tools group row; the system band onto the system row.
await hover(bands(m)[1])
assert.ok(flatRow(m, 'Tool Schemas').closest('.lc-br-elem')?.className.includes('lc-br-elem-echo'))
await unhover(bands(m)[1])
await hover(bands(m)[0])
assert.ok(flatRow(m, 'System Prompt').closest('.lc-br-elem')?.className.includes('lc-br-elem-echo'))
await m.unmount()
})
test('a tiny item stays a hoverable filament (the minBand floor) while the tooltip reports the true share', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 2, inject: 0, skill: 0, assistant: 998, tool: 0, total: 1000 },