feat(client): add a DNA mode to the Context browser's composition bar
A title-row toggle redraws the composition bar as one band per context item — the system prompt, every tool schema, every surface message — in prompt order (the header prefix first, then the message flow by seq), the order the model reads the context and the order items joined it. The header epoch's own seq is bookkeeping (dsh logs request/header at dispatch of the first request using it), never an ordering key: a mid-session refresh must not park the schemas behind the tool results they describe. Bands floor at 0.35% of the occupied region so a tiny item stays a hoverable filament (tooltips still report true shares); hovering names the item with its time, a mirrored category hover lights the category's bands as a group, and clicking a band opens its category + element row below, scrolling it into view.
This commit is contained in:
1 parent
d52c43ae18
commit
568d837f80
10 files changed
+519
-32
No files matched your search
@@ -20,6 +20,10 @@ export interface PartsPart {
|
||||
value: number
|
||||
/** Heuristic count shown by the legend and tooltips (defaults to value). */
|
||||
raw?: number
|
||||
/** Tooltip display name (defaults to the category label of `key`) — DNA mode names individual items. */
|
||||
label?: string
|
||||
/** Shared-hover group: an incoming hover key equal to a part's group lights it (DNA bands light per category). */
|
||||
group?: string
|
||||
}
|
||||
|
||||
export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [
|
||||
@@ -31,6 +35,9 @@ export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [
|
||||
{ key: 'tool', color: '#14b8a6' },
|
||||
]
|
||||
|
||||
/** Category key → bar color, for per-item bands (the browser's DNA mode) that bypass the CATS-order part builders. */
|
||||
export const CAT_COLOR = Object.fromEntries(CATS.map(c => [c.key, c.color])) as Record<Category | 'system' | 'tools', string>
|
||||
|
||||
const MESSAGE_CATS: readonly (Category | 'system' | 'tools')[] = ['user', 'inject', 'assistant', 'tool']
|
||||
|
||||
export function partsOf(breakdown: Snapshot['current'] | RequestRecord): PartsPart[] {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent
|
||||
import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderTool, type RequestRecord, type SurfaceNode } from '../../shared/types'
|
||||
import { assemble } from '../assemble'
|
||||
import type { Assembled } from '../assemble'
|
||||
import { CATS, partsOf } from '../categories'
|
||||
import { CATS, CAT_COLOR, partsOf } from '../categories'
|
||||
import { dnaOf } from '../dna'
|
||||
import type { DnaItem } from '../dna'
|
||||
import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../services'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { blockSummaryOf, callSummaryOf, parseCallArgs } from '../callSummary'
|
||||
@@ -564,6 +566,12 @@ function lastOfTurn(requests: RequestRecord[], turn: number): RequestRecord | nu
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* DNA bands keep at least this share of the occupied region, so a tiny item (a 25-token user message in a 40k
|
||||
* context) stays a hoverable/clickable filament instead of a sub-pixel sliver. Tooltips still report true shares.
|
||||
*/
|
||||
const DNA_MIN_BAND = 0.35
|
||||
|
||||
export function makeContextBrowser(
|
||||
kit: ViewKit,
|
||||
StackedBar: (props: StackedBarProps) => ReactElement,
|
||||
@@ -589,6 +597,9 @@ export function makeContextBrowser(
|
||||
// compares epochs.
|
||||
const [rowQuery, setRowQuery] = useState('')
|
||||
const [toolSort, setToolSort] = useState<'size' | 'name'>('size')
|
||||
// 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)
|
||||
const [dnaKey, setDnaKey] = useState<string | null>(null)
|
||||
// Every open-category change (toggle, step pick, pin, brief reveal) reports outward so the Context tab
|
||||
// can focus the trend chart on the open category.
|
||||
const onOpenCat = props.onOpenCat
|
||||
@@ -707,6 +718,41 @@ export function makeContextBrowser(
|
||||
|
||||
const byCat = byCatOf(view)
|
||||
|
||||
// DNA mode: per-item bands in prompt order (dna.ts). The band label names the item the way its accordion row would
|
||||
// (skill name, tool name, injection form, else the category label), with the item's time appended.
|
||||
const dnaLabel = (it: DnaItem): string => {
|
||||
let base: string
|
||||
if (!('node' in it)) {
|
||||
base = it.cat === 'system' ? catLabel('system') : it.key.slice('tool:'.length)
|
||||
} else {
|
||||
const n = it.node
|
||||
base = n.skill !== undefined ? t('node.skillTag', { name: n.skill })
|
||||
: n.cat === 'tool' ? (n.tool ?? '?')
|
||||
: n.cat === 'inject' ? t('form.' + (n.form || 'context'))
|
||||
: catLabel(n.cat)
|
||||
}
|
||||
return it.time !== undefined ? base + ' · ' + fmtTime(it.time) : base
|
||||
}
|
||||
const dnaItems = dna ? dnaOf(view) : null
|
||||
const dnaByKey = new Map(dnaItems?.map(it => [it.key, it] as const) ?? [])
|
||||
const dnaParts = dnaItems?.map(it => ({
|
||||
key: it.key,
|
||||
color: CAT_COLOR[it.cat],
|
||||
value: it.tokens,
|
||||
label: dnaLabel(it),
|
||||
group: it.cat,
|
||||
})) ?? 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)
|
||||
/* v8 ignore next 1 -- the bar only reports keys of the parts it was handed; defensive. */
|
||||
if (it === undefined) return
|
||||
setCat(it.cat)
|
||||
setRowQuery('')
|
||||
setOpenElem(key)
|
||||
focusScrollRef.current = true
|
||||
}
|
||||
|
||||
const toolCount = (c: string): number => countOf(view, byCat, c)
|
||||
|
||||
// A category holding exactly one item opens that row with the category, so
|
||||
@@ -960,6 +1006,15 @@ export function makeContextBrowser(
|
||||
<div className="lc-card" ref={rootRef}>
|
||||
<div className="lc-card-title">
|
||||
<span className="lc-card-title-text">{t('browser.title')}</span>
|
||||
<span className="lc-gran lc-br-dna-ctl" role="group" title={t('browser.dnaTip')}>
|
||||
<button
|
||||
type="button"
|
||||
className={'lc-gran-btn' + (dna ? ' lc-gran-on' : '')}
|
||||
onClick={() => { setDna(on => !on) }}
|
||||
>
|
||||
{t('browser.dna')}
|
||||
</button>
|
||||
</span>
|
||||
<span className="lc-br-hint">{t('browser.deltaHint')}</span>
|
||||
<select
|
||||
className="lc-br-pick"
|
||||
@@ -987,16 +1042,19 @@ export function makeContextBrowser(
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<div className="lc-br-bar">
|
||||
<div className={'lc-br-bar' + (dna ? ' lc-br-bar-dna' : '')}>
|
||||
<StackedBar
|
||||
parts={parts}
|
||||
parts={dnaParts ?? parts}
|
||||
height={10}
|
||||
// Mirrored hover link (see `linked` above): while the browser shows the live surface, its bar highlights the shared category
|
||||
// key and reports hovers back to the overview; tip stays off — a cross-card hover must not float a second tooltip over a bar
|
||||
// the pointer does not rest on.
|
||||
hoverKey={linked ? linkKey : undefined}
|
||||
onHoverKey={linked ? props.onHoverKey : undefined}
|
||||
tip={false}
|
||||
// key — in DNA mode an incoming category key lights that category's BANDS (the parts' `group`) — and reports hovers back to
|
||||
// the overview; in DNA mode hovers stay local (per-item tooltip) instead. 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.
|
||||
hoverKey={dna ? dnaKey ?? linkKey : linked ? linkKey : undefined}
|
||||
onHoverKey={dna ? setDnaKey : linked ? props.onHoverKey : undefined}
|
||||
tip={dna}
|
||||
onPickKey={dna ? pickDna : undefined}
|
||||
minBand={dna ? DNA_MIN_BAND : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,15 @@ export interface StackedBarProps {
|
||||
height?: number
|
||||
hoverKey?: string | null
|
||||
onHoverKey?: (key: string | null) => void
|
||||
/** Segment click (the browser's DNA mode opens the band's accordion row): segments gain the pick cursor and report their key. */
|
||||
onPickKey?: (key: string) => void
|
||||
/**
|
||||
* Minimum width each segment keeps, as a share of the OCCUPIED region (the browser's DNA bands: a tiny item must
|
||||
* stay a hoverable filament instead of rendering sub-pixel). Bands below the floor pin to it and the rest rescale
|
||||
* proportionally; the floor caps at the equal split (100/N), where the strip degrades to uniform bands. Tooltips
|
||||
* keep reporting TRUE shares — only widths are floored.
|
||||
*/
|
||||
minBand?: number
|
||||
/**
|
||||
* Render the hover tooltip (default true); a bar that only MIRRORS another card's hover turns it off, so the tooltip floats only over the
|
||||
* surface the pointer actually rests on.
|
||||
@@ -46,6 +55,28 @@ export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactE
|
||||
const usedPct = scale > 0 ? total / scale * 100 : 0
|
||||
const hovering = props.hoverKey !== null && props.hoverKey !== undefined
|
||||
const showBox = free > 0 && hovering
|
||||
const pickKey = props.onPickKey
|
||||
const minBand = props.minBand !== undefined && props.minBand > 0 ? props.minBand : 0
|
||||
|
||||
// The renderable segments (zero-value parts skip) with their BAR-unit widths, floored at `minBand` in occupied
|
||||
// units: pinned bands take the floor, the rest rescale into the remaining room so the strip still sums to the
|
||||
// occupied region exactly. At least one band always stays unpinned (the shares sum to 100, the floor ≤ 100/N),
|
||||
// so restRaw never divides by zero.
|
||||
const visible = total > 0 ? props.parts.filter(p => p.value > 0) : []
|
||||
const widths = ((): number[] => {
|
||||
if (minBand === 0 || visible.length < 2) return visible.map(p => p.value / scale * 100)
|
||||
const shares = visible.map(p => p.value / total * 100)
|
||||
const floor = Math.min(minBand, 100 / visible.length)
|
||||
let pinned = 0
|
||||
let pinnedRaw = 0
|
||||
for (const s of shares) {
|
||||
if (s < floor) { pinned += 1; pinnedRaw += s }
|
||||
}
|
||||
if (pinned === 0) return visible.map(p => p.value / scale * 100)
|
||||
const restRoom = 100 - pinned * floor
|
||||
const restRaw = 100 - pinnedRaw
|
||||
return shares.map(s => (s < floor ? floor : s * restRoom / restRaw) * total / scale)
|
||||
})()
|
||||
|
||||
// The band lays out in WINDOW units (`ratio × max` → `max`) scaled onto whatever total the bar spans, so it stays the same physical
|
||||
// slice with or without a free track (once used exceeds the window, the stripes sit over the outermost segments).
|
||||
@@ -74,21 +105,21 @@ export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactE
|
||||
} else {
|
||||
let acc = 0
|
||||
// Counts come from the part's heuristic `raw` figure (the ring
|
||||
// panel's rows); widths ride the anchored `value` (the ring's fill).
|
||||
// panel's rows); widths ride the floored `widths` so the tooltip centers on the RENDERED segment.
|
||||
let rawTotal = 0
|
||||
for (const p of props.parts) rawTotal += p.raw ?? p.value
|
||||
for (const p of props.parts) {
|
||||
const pct = scale > 0 ? p.value / scale * 100 : 0
|
||||
if (p.key === props.hoverKey && p.value > 0) {
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const p = visible[i]
|
||||
if (p.key === props.hoverKey) {
|
||||
const count = p.raw ?? p.value
|
||||
tip = {
|
||||
text: `${catLabel(p.key)} ≈${fmt(count)} (${rawTotal > 0 ? Math.round(count / rawTotal * 100) : 0}%) `
|
||||
text: `${p.label ?? catLabel(p.key)} ≈${fmt(count)} (${rawTotal > 0 ? Math.round(count / rawTotal * 100) : 0}%) `
|
||||
+ t('overview.ofUsed'),
|
||||
leftPct: Math.max(12, Math.min(acc + pct / 2, 88)),
|
||||
leftPct: Math.max(12, Math.min(acc + widths[i] / 2, 88)),
|
||||
}
|
||||
break
|
||||
}
|
||||
acc += pct
|
||||
acc += widths[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,20 +134,20 @@ export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactE
|
||||
setReserveOn(false)
|
||||
}}
|
||||
>
|
||||
{total > 0
|
||||
? props.parts.map((p) => {
|
||||
if (!p.value) return null
|
||||
const on = props.hoverKey !== undefined && props.hoverKey === p.key
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
className={'lc-stacked-seg' + (on ? ' lc-stacked-seg-on' : '')}
|
||||
style={{ width: `${p.value / scale * 100}%`, background: p.color }}
|
||||
onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(p.key) }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
{visible.map((p, i) => {
|
||||
// Exact-key hover lights the one segment; a part's `group` answers a mirrored CATEGORY hover (DNA bands light per category).
|
||||
const on = props.hoverKey !== undefined
|
||||
&& (props.hoverKey === p.key || (p.group !== undefined && props.hoverKey === p.group))
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
className={'lc-stacked-seg' + (on ? ' lc-stacked-seg-on' : '') + (pickKey !== undefined ? ' lc-stacked-seg-pick' : '')}
|
||||
style={{ width: `${widths[i]}%`, background: p.color }}
|
||||
onMouseEnter={() => { if (props.onHoverKey !== undefined) props.onHoverKey(p.key) }}
|
||||
onClick={pickKey !== undefined ? () => { pickKey(p.key) } : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{free > 0 ? (
|
||||
<div
|
||||
key="free"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The browser's DNA mode: decompose an assembled context into ONE band per
|
||||
* item (the system prompt, every tool schema, every surface message) and
|
||||
* lay them out in the order the model actually reads them — the context's
|
||||
* "fingerprint" strip behind the composition bar's `dna` toggle.
|
||||
*
|
||||
* The order is PROMPT order, which is also the order items joined the
|
||||
* context: the system prompt and tool schemas prefix every request, so they
|
||||
* lead the strip; the message flow follows in seq (chronological) order.
|
||||
* The header epoch's own seq is NOT usable as an ordering key: dsh appends
|
||||
* `request/header` at dispatch time of the first request that uses a header
|
||||
* (agent.ts — 'initial'/'resume'/'change'/'series'), a bookkeeping snapshot
|
||||
* that lands AFTER every message already in context. A mid-session refresh
|
||||
* would otherwise park the system/tools bands in the middle of the strip,
|
||||
* behind the very tool results those schemas describe. A refreshed epoch
|
||||
* REPLACES the prefix in place — the strip reflects that by keeping the
|
||||
* current epoch's bands at the front (the band's tooltip carries the
|
||||
* epoch's time).
|
||||
*/
|
||||
|
||||
import type { Category, SurfaceNode } from '../shared/types'
|
||||
import type { Assembled } from './assemble'
|
||||
|
||||
/**
|
||||
* One band of the DNA strip. Header bands carry their tokens only; message
|
||||
* bands hand the node through so the label builder reads tool/form/skill
|
||||
* straight off it (the union discriminates on `cat` — no defensive guards).
|
||||
*/
|
||||
export type DnaItem =
|
||||
| { key: string; cat: 'system' | 'tools'; tokens: number; time?: number }
|
||||
| { key: string; cat: Category; tokens: number; time?: number; node: SurfaceNode }
|
||||
|
||||
export function dnaOf(view: Assembled): DnaItem[] {
|
||||
const items: DnaItem[] = []
|
||||
if (view.header !== null) {
|
||||
if (view.header.systemTokens !== undefined) {
|
||||
items.push({ key: 'sys', cat: 'system', tokens: view.header.systemTokens, time: view.header.time })
|
||||
}
|
||||
for (const tool of view.header.tools) {
|
||||
items.push({ key: 'tool:' + tool.name, cat: 'tools', tokens: tool.tokens })
|
||||
}
|
||||
}
|
||||
// `assemble` guarantees seq order; zero-token items keep their band (the
|
||||
// bar drops zero widths, the reading order stays truthful).
|
||||
for (const n of view.nodes) {
|
||||
items.push({
|
||||
key: 'n' + String(n.seq),
|
||||
cat: n.cat,
|
||||
tokens: n.tokens,
|
||||
node: n,
|
||||
...(n.time !== undefined ? { time: n.time } : {}),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -174,6 +174,8 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'browser.missingLive': '… 另有 {n} 条更早的消息也在上下文中(超出展示窗口)',
|
||||
'browser.approx': '该步骤涉及的部分已移除消息超出保留范围,以下为近似构成',
|
||||
'browser.deltaHint': '对比上轮末步的变动',
|
||||
'browser.dna': 'DNA 模式',
|
||||
'browser.dnaTip': '按模型读取上下文的次序逐条展示每一项:系统提示词、工具定义,然后是按时间排列的消息;悬停查看条目,点击定位到下方列表',
|
||||
'browser.noHeader': '此数据来自旧版插件:仅提供 token 估算,无实际内容',
|
||||
'browser.noEpoch': '该步骤的头部内容(系统提示词 / 工具定义)不在保留范围内',
|
||||
'browser.headerMetaOnly': '此环境不支持按需读取头部内容,仅显示 token 估算',
|
||||
@@ -397,6 +399,8 @@ export const DICT_EN: Record<string, string> = {
|
||||
'browser.missingLive': '… {n} earlier messages are also part of the context (outside the served window)',
|
||||
'browser.approx': 'Some removed messages of this step exceed retention — the makeup below is approximate',
|
||||
'browser.deltaHint': 'vs previous turn',
|
||||
'browser.dna': 'DNA Mode',
|
||||
'browser.dnaTip': 'One band per item in the order the model reads the context — system prompt, tool schemas, then messages chronologically; hover for details, click to open its row below',
|
||||
'browser.noHeader': 'Served by an older plugin build: token estimates only, no content',
|
||||
'browser.noEpoch': 'The header content (System Prompt / Tool Schemas) of this step is outside retention',
|
||||
'browser.headerMetaOnly': 'This environment cannot fetch header content on demand; token estimates only',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/* The Context browser: category rows, element rows, delta pills and disclosure chevrons (browser.tsx). */
|
||||
/* The DNA toggle rides the card title next to the title text; the hint's own auto margin keeps pushing the hint+picker right
|
||||
(a bare .lc-gran would add a second auto margin and split the free space). */
|
||||
.lc-br-dna-ctl { margin-left: 0; flex: none; }
|
||||
/* DNA mode: per-item bands read as one gel strip — a 1px card-colored divider keeps adjacent same-category bands distinguishable. */
|
||||
.lc-br-bar-dna .lc-stacked-seg { box-shadow: inset -1px 0 0 var(--dsw-alias-bg-layer-1); }
|
||||
.lc-br-hint { margin-left: auto; color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 400; white-space: nowrap; }
|
||||
.lc-br-pick { font: inherit; font-size: 12px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; padding: 3px 6px; max-width: 240px; }
|
||||
.lc-br-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 8px; color: var(--dsw-alias-label-secondary); }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
.lc-bar-tip { bottom: calc(100% + 6px); transform: translateX(-50%); z-index: 5; white-space: nowrap; padding: 3px 8px; }
|
||||
.lc-bar-tip-on { opacity: 1; }
|
||||
.lc-stacked > div { height: 100%; }
|
||||
/* Pickable segments (the browser's DNA bands open their accordion row on click). */
|
||||
.lc-stacked-seg-pick { cursor: pointer; }
|
||||
.lc-stacked-seg { transition: filter var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-stacked-free { box-sizing: border-box; transition: box-shadow var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-stacked-seg-on { filter: brightness(1.18); }
|
||||
|
||||
@@ -1546,3 +1546,164 @@ describe('ContextBrowser open-category reporting', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextBrowser DNA mode', () => {
|
||||
const dnaHeaders: ContextHeaders = {
|
||||
headers: [{
|
||||
seq: 1, time: 1000, systemTokens: 100,
|
||||
tools: [{ name: 'bash', tokens: 50 }, { name: 'write', tokens: 25 }],
|
||||
}],
|
||||
}
|
||||
const dnaData = tl({
|
||||
current: { system: 100, tools: 75, user: 20, inject: 10, assistant: 40, tool: 30, total: 275 },
|
||||
requests: [req({ seq: 20, turn: 1, step: 0, total: 275 })],
|
||||
nodes: [
|
||||
node({ seq: 2, tokens: 20, time: 2000, text: 'hi' }),
|
||||
node({ seq: 3, cat: 'tool', tokens: 30, time: 3000, tool: 'bash' }),
|
||||
node({ seq: 4, cat: 'assistant', tokens: 40 }),
|
||||
node({ seq: 5, cat: 'inject', tokens: 10, form: 'notice' }),
|
||||
],
|
||||
})
|
||||
|
||||
function dnaButton(m: Mounted): HTMLElement {
|
||||
return query(m.container, '.lc-br-dna-ctl .lc-gran-btn')
|
||||
}
|
||||
|
||||
function bands(m: Mounted): HTMLElement[] {
|
||||
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 })))
|
||||
// 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)
|
||||
assert.deepEqual(normal, [100, 75, 20, 10, 40, 30].map(v => `${v / 275 * 100}%`))
|
||||
assert.ok(!bands(m)[0].className.includes('lc-stacked-seg-pick'))
|
||||
assert.ok(!query(m.container, '.lc-br-bar').className.includes('lc-br-bar-dna'))
|
||||
assert.equal(queryAll(m.container, '.lc-bar-tip').length, 0)
|
||||
|
||||
const btn = dnaButton(m)
|
||||
assert.equal(text(btn), 'DNA Mode')
|
||||
assert.equal(query(m.container, '.lc-br-dna-ctl').getAttribute('title'), kit.t('browser.dnaTip'))
|
||||
await click(btn)
|
||||
// Prompt order: the system prompt, the two tool schemas in producer order, then the message flow by seq —
|
||||
// NOT the fold's category order and NOT the header event's bookkeeping seq (see dna.ts).
|
||||
const segs = bands(m)
|
||||
assert.equal(segs.length, 7)
|
||||
assert.deepEqual(segs.map(seg => seg.style.width), [100, 50, 25, 20, 30, 40, 10].map(v => `${v / 275 * 100}%`))
|
||||
assert.ok(segs.every(seg => seg.className.includes('lc-stacked-seg-pick')))
|
||||
assert.ok(query(m.container, '.lc-br-bar').className.includes('lc-br-bar-dna'))
|
||||
assert.equal(queryAll(m.container, '.lc-bar-tip').length, 1, 'the per-item tooltip slot mounts')
|
||||
assert.ok(btn.className.includes('lc-gran-on'))
|
||||
|
||||
await click(btn)
|
||||
assert.equal(bands(m).length, 6, 'toggling off restores the category composition')
|
||||
assert.ok(!btn.className.includes('lc-gran-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('band hover floats the per-item tooltip locally; a mirrored category key lights that category\'s bands', async () => {
|
||||
const hovers: (string | null)[] = []
|
||||
const wired = (hoverKey: string | null) => h(Browser, props({ data: dnaData, headers: dnaHeaders, hoverKey, onHoverKey: (k) => { hovers.push(k) } }))
|
||||
const m = await mount(wired(null))
|
||||
await click(dnaButton(m))
|
||||
|
||||
// A band hover is LOCAL: the tooltip names the item (category label + join time); nothing reports upward.
|
||||
await hover(bands(m)[3])
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
assert.ok(tip.className.includes('lc-bar-tip-on'))
|
||||
assert.equal(text(tip), `User Messages · ${kit.fmtTime(2000)} ≈20 (7%) of used context`)
|
||||
assert.deepEqual(hovers, [], 'DNA hovers stay on the bar — the overview keeps showing the live category composition')
|
||||
assert.ok(bands(m)[3].className.includes('lc-stacked-seg-on'))
|
||||
assert.ok(query(m.container, '.lc-stacked').className.includes('lc-stacked-dim'))
|
||||
// A node without a logged time shows the bare label.
|
||||
await hover(bands(m)[5])
|
||||
assert.equal(text(tip), 'Assistant Messages ≈40 (15%) of used context')
|
||||
await unhover(query(m.container, '.lc-stacked'))
|
||||
assert.ok(!tip.className.includes('lc-bar-tip-on'))
|
||||
|
||||
// The incoming category mirror still works: the open category's bands light as a group (no tooltip for a mirrored hover).
|
||||
await m.update(wired('tool'))
|
||||
assert.deepEqual(bands(m).map(seg => seg.className.includes('lc-stacked-seg-on')), [false, false, false, false, true, false, false])
|
||||
assert.ok(!query(m.container, '.lc-bar-tip').className.includes('lc-bar-tip-on'))
|
||||
await m.update(wired('free'))
|
||||
assert.ok(bands(m).every(seg => !seg.className.includes('lc-stacked-seg-on')), "the overview's free key lights nothing here")
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('band labels: skill tag, nameless tool result, formless injection; a header-less surface shows message bands only', async () => {
|
||||
const data = tl({
|
||||
current: { system: 0, tools: 0, user: 0, inject: 40, assistant: 0, tool: 20, total: 60 },
|
||||
nodes: [
|
||||
node({ seq: 1, cat: 'inject', tokens: 10, skill: 'code' }),
|
||||
node({ seq: 2, cat: 'tool', tokens: 20 }),
|
||||
node({ seq: 3, cat: 'inject', tokens: 30 }),
|
||||
],
|
||||
})
|
||||
const m = await mount(h(Browser, props({ data, headers: null })))
|
||||
await click(dnaButton(m))
|
||||
assert.equal(bands(m).length, 3)
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
await hover(bands(m)[0])
|
||||
assert.equal(text(tip), 'Skill · code ≈10 (17%) of used context')
|
||||
await hover(bands(m)[1])
|
||||
assert.equal(text(tip), '? ≈20 (33%) of used context')
|
||||
await hover(bands(m)[2])
|
||||
assert.equal(text(tip), 'Context Injection ≈30 (50%) of used context')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('clicking a band opens its category + element row below and scrolls it into view; a stale row filter is cleared', async () => {
|
||||
const scrolls: unknown[] = []
|
||||
const orig = Element.prototype.scrollIntoView
|
||||
Element.prototype.scrollIntoView = function (this: Element, arg?: unknown) { scrolls.push(arg) } as never
|
||||
try {
|
||||
const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders })))
|
||||
await click(dnaButton(m))
|
||||
// Band n3 (index 4) opens the tool-results category on that exact row.
|
||||
await click(bands(m)[4])
|
||||
assert.equal(queryAll(m.container, '.lc-br-body').length, 1)
|
||||
const onRows = queryAll(m.container, '.lc-br-elem-on')
|
||||
assert.equal(onRows.length, 1)
|
||||
assert.ok(text(onRows[0]).includes('bash'))
|
||||
assert.deepEqual(scrolls, [{ block: 'nearest' }])
|
||||
|
||||
// A stale row filter cannot hide the clicked band's row.
|
||||
await click(catRow(m, 'user'))
|
||||
await typeToolSearch(m, 'zzz')
|
||||
assert.ok(text(query(m.container, '.lc-br-body')).includes('No rows match'))
|
||||
await click(bands(m)[3])
|
||||
assert.equal(query<HTMLInputElement>(m.container, '.lc-br-tool-search').value, '')
|
||||
assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 1)
|
||||
assert.ok(text(query(m.container, '.lc-br-body')).includes('hi'))
|
||||
|
||||
// The system band opens the system section (metadata-only note without a header fetcher).
|
||||
await click(bands(m)[0])
|
||||
assert.ok(text(query(m.container, '.lc-br-body')).includes('token estimates only'))
|
||||
await m.unmount()
|
||||
} finally {
|
||||
Element.prototype.scrollIntoView = orig
|
||||
}
|
||||
})
|
||||
|
||||
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, assistant: 998, tool: 0, total: 1000 },
|
||||
nodes: [node({ seq: 1, tokens: 2, text: 'ok' }), node({ seq: 2, cat: 'assistant', tokens: 998 })],
|
||||
})
|
||||
const m = await mount(h(Browser, props({ data, headers: null })))
|
||||
await click(dnaButton(m))
|
||||
const segs = bands(m)
|
||||
assert.equal(segs.length, 2)
|
||||
// 2/1000 ≈ 0.2% would render sub-pixel: the band pins to the 0.35% floor, the big band rescales.
|
||||
assert.equal(segs[0].style.width, '0.35%')
|
||||
assert.ok(Math.abs(parseFloat(segs[1].style.width) - 99.65) < 0.001)
|
||||
await hover(segs[0])
|
||||
assert.equal(text(query(m.container, '.lc-bar-tip')), 'User Messages ≈2 (0%) of used context')
|
||||
// Category mode keeps exact widths (no floor outside DNA mode).
|
||||
await click(dnaButton(m))
|
||||
assert.deepEqual(bands(m).map(seg => seg.style.width), ['0.2%', '99.8%'])
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { makeLegend, makeStackedBar } from '../../../src/client/components/stackedBar'
|
||||
import type { PartsPart } from '../../../src/client/categories'
|
||||
import { hover, makeKit, mount, query, queryAll, unhover } from '../helpers/kit'
|
||||
import { click, hover, makeKit, mount, query, queryAll, unhover } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const StackedBar = makeStackedBar(kit)
|
||||
@@ -18,9 +18,9 @@ function part(key: string, value: number, raw?: number): PartsPart {
|
||||
}
|
||||
|
||||
/** A parent that really holds the hover key, so hovering re-renders the bar. */
|
||||
function HoverHarness(props: { parts: PartsPart[]; max?: number; reserve?: { ratio: number; label: string } }) {
|
||||
function HoverHarness(props: { parts: PartsPart[]; max?: number; minBand?: number; reserve?: { ratio: number; label: string } }) {
|
||||
const [hoverKey, setHoverKey] = useState<string | null>(null)
|
||||
return h(StackedBar, { parts: props.parts, max: props.max, hoverKey, onHoverKey: setHoverKey, reserve: props.reserve })
|
||||
return h(StackedBar, { parts: props.parts, max: props.max, minBand: props.minBand, hoverKey, onHoverKey: setHoverKey, reserve: props.reserve })
|
||||
}
|
||||
|
||||
describe('StackedBar layout', () => {
|
||||
@@ -243,3 +243,89 @@ describe('Legend', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
describe('StackedBar DNA bands', () => {
|
||||
test('a part label overrides the category name in the tooltip', async () => {
|
||||
const m = await mount(h(HoverHarness, {
|
||||
parts: [{ key: 'n3', color: '#123456', value: 30, label: 'bash · 09:00:00' }, part('user', 70)],
|
||||
}))
|
||||
await hover(queryAll(m.container, '.lc-stacked-seg')[0])
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
assert.ok(tip.className.includes('lc-bar-tip-on'))
|
||||
assert.equal(tip.textContent, 'bash · 09:00:00 ≈30 (30%) of used context')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('group hover: a mirrored category key lights every band of that group but floats no tooltip', async () => {
|
||||
const parts: PartsPart[] = [
|
||||
{ key: 'n1', color: '#123456', value: 10, group: 'tool' },
|
||||
{ key: 'n2', color: '#123456', value: 20, group: 'tool' },
|
||||
{ key: 'n3', color: '#654321', value: 70, group: 'user' },
|
||||
]
|
||||
const m = await mount(h(StackedBar, { parts, hoverKey: 'tool' }))
|
||||
const segs = queryAll(m.container, '.lc-stacked-seg')
|
||||
assert.ok(segs[0].className.includes('lc-stacked-seg-on'))
|
||||
assert.ok(segs[1].className.includes('lc-stacked-seg-on'))
|
||||
assert.ok(!segs[2].className.includes('lc-stacked-seg-on'))
|
||||
assert.ok(query(m.container, '.lc-stacked').className.includes('lc-stacked-dim'))
|
||||
assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-tip lc-bar-tip', 'no exact key — no tooltip')
|
||||
// The exact key still lights just its own band.
|
||||
await m.update(h(StackedBar, { parts, hoverKey: 'n3' }))
|
||||
const segs2 = queryAll(m.container, '.lc-stacked-seg')
|
||||
assert.ok(!segs2[0].className.includes('lc-stacked-seg-on'))
|
||||
assert.ok(!segs2[1].className.includes('lc-stacked-seg-on'))
|
||||
assert.ok(segs2[2].className.includes('lc-stacked-seg-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('onPickKey: segments gain the pick cursor and report their key on click', async () => {
|
||||
const picks: string[] = []
|
||||
const m = await mount(h(StackedBar, { parts: [part('a', 40), part('b', 60)], onPickKey: (k) => { picks.push(k) } }))
|
||||
const segs = queryAll(m.container, '.lc-stacked-seg')
|
||||
assert.ok(segs[0].className.includes('lc-stacked-seg-pick'))
|
||||
assert.ok(segs[1].className.includes('lc-stacked-seg-pick'))
|
||||
await click(segs[1])
|
||||
assert.deepEqual(picks, ['b'])
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
describe('StackedBar minBand floor', () => {
|
||||
test('a sub-pixel band pins to the floor and the rest rescale; the tooltip keeps the true share', async () => {
|
||||
const m = await mount(h(HoverHarness, { parts: [part('assistant', 990), part('user', 10)], minBand: 5 }))
|
||||
const segs = queryAll(m.container, '.lc-stacked-seg')
|
||||
assert.equal(segs[0].style.width, '95%')
|
||||
assert.equal(segs[1].style.width, '5%')
|
||||
await hover(segs[1])
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
assert.equal(tip.textContent, 'User Messages ≈10 (1%) of used context')
|
||||
assert.equal(tip.style.left, '88%', 'the tooltip clamps to the bar edge on a floored sliver')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('the floor caps at the equal split when bands outnumber it', async () => {
|
||||
const m = await mount(h(StackedBar, { parts: [part('assistant', 90), part('user', 5), part('tool', 5)], minBand: 50 }))
|
||||
const floor = Math.min(50, 100 / 3)
|
||||
const segs = queryAll(m.container, '.lc-stacked-seg')
|
||||
assert.equal(segs[1].style.width, `${floor}%`)
|
||||
assert.equal(segs[2].style.width, `${floor}%`)
|
||||
// The unpinned band rescales into the remaining room (the same arithmetic the component runs).
|
||||
assert.equal(segs[0].style.width, `${90 * (100 - 2 * floor) / 90}%`)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a single visible band spans the bar regardless of the floor; zero-value parts stay skipped', async () => {
|
||||
const m = await mount(h(StackedBar, { parts: [part('user', 0), part('system', 100)], minBand: 10 }))
|
||||
const segs = queryAll(m.container, '.lc-stacked-seg')
|
||||
assert.equal(segs.length, 1)
|
||||
assert.equal(segs[0].style.width, '100%')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('minBand 0 or no band below the floor keeps exact widths', async () => {
|
||||
const m = await mount(h(StackedBar, { parts: [part('assistant', 99), part('user', 1)], minBand: 0 }))
|
||||
assert.deepEqual(queryAll(m.container, '.lc-stacked-seg').map(s => s.style.width), ['99%', '1%'])
|
||||
await m.unmount()
|
||||
const m2 = await mount(h(StackedBar, { parts: [part('assistant', 60), part('user', 40)], minBand: 1 }))
|
||||
assert.deepEqual(queryAll(m2.container, '.lc-stacked-seg').map(s => s.style.width), ['60%', '40%'])
|
||||
await m2.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// dnaOf (src/client/dna.ts): the DNA strip's per-item decomposition — the
|
||||
// header epoch's bands (system prompt, then tool schemas in producer order)
|
||||
// lead, the message flow follows in seq order: the order the model reads
|
||||
// the context, which is also the order items joined it.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { dnaOf } from '../../src/client/dna'
|
||||
import type { Assembled } from '../../src/client/assemble'
|
||||
import type { HeaderRecord, HeaderTool, SurfaceNode } from '../../src/shared/types'
|
||||
|
||||
function asm(over: Partial<Assembled>): Assembled {
|
||||
return { live: true, header: null, nodes: [], missingLive: 0, approximate: false, ...over }
|
||||
}
|
||||
|
||||
function header(over: Partial<HeaderRecord>): HeaderRecord {
|
||||
return { seq: 10, time: 9000, tools: [], ...over }
|
||||
}
|
||||
|
||||
function tool(name: string, tokens: number): HeaderTool {
|
||||
return { name, tokens }
|
||||
}
|
||||
|
||||
function node(over: Partial<SurfaceNode> & { seq: number }): SurfaceNode {
|
||||
return { cat: 'user', tokens: 5, ...over }
|
||||
}
|
||||
|
||||
describe('dnaOf', () => {
|
||||
test('empty context yields no bands', () => {
|
||||
assert.deepEqual(dnaOf(asm({})), [])
|
||||
})
|
||||
|
||||
test('header epoch bands lead: the system prompt first, then the tool schemas in producer order', () => {
|
||||
const items = dnaOf(asm({
|
||||
header: header({ systemTokens: 100, tools: [tool('bash', 30), tool('write', 20)] }),
|
||||
nodes: [node({ seq: 1 })],
|
||||
}))
|
||||
assert.deepEqual(items.map(i => i.key), ['sys', 'tool:bash', 'tool:write', 'n1'])
|
||||
assert.deepEqual(items.map(i => i.cat), ['system', 'tools', 'tools', 'user'])
|
||||
assert.deepEqual(items.map(i => i.tokens), [100, 30, 20, 5])
|
||||
assert.equal(items[0].time, 9000, 'the system band carries the epoch time')
|
||||
assert.equal('time' in items[1], false, 'tool bands carry no time')
|
||||
assert.ok(!('node' in items[0]), 'header bands carry no node')
|
||||
})
|
||||
|
||||
test('an epoch without a system prompt yields tool bands only', () => {
|
||||
const items = dnaOf(asm({ header: header({ tools: [tool('bash', 30)] }) }))
|
||||
assert.deepEqual(items.map(i => i.key), ['tool:bash'])
|
||||
})
|
||||
|
||||
test('message bands follow node seqs and hand the node through, with time only when logged', () => {
|
||||
const a = node({ seq: 3, tokens: 7, time: 3000 })
|
||||
const b = node({ seq: 8, cat: 'tool', tokens: 9 })
|
||||
const items = dnaOf(asm({ nodes: [a, b] }))
|
||||
assert.deepEqual(items.map(i => i.key), ['n3', 'n8'])
|
||||
assert.deepEqual(items.map(i => i.cat), ['user', 'tool'])
|
||||
assert.equal(items[0].time, 3000)
|
||||
assert.equal('time' in items[1], false)
|
||||
assert.equal((items[0] as { node?: SurfaceNode }).node, a)
|
||||
assert.equal((items[1] as { node?: SurfaceNode }).node, b)
|
||||
})
|
||||
|
||||
test('a mid-session epoch refresh keeps the header bands at the FRONT (the epoch seq is bookkeeping, not prompt position)', () => {
|
||||
// The regression this pins: dsh appends `request/header` at dispatch time of the first request using the header, so a
|
||||
// refreshed epoch's seq lands AFTER every message already in context — epoch-seq ordering parked the system/tools
|
||||
// bands behind the tool results those very schemas describe.
|
||||
const items = dnaOf(asm({
|
||||
header: header({ seq: 50, systemTokens: 100, tools: [tool('bash', 30)] }),
|
||||
nodes: [node({ seq: 3 }), node({ seq: 20, cat: 'tool' }), node({ seq: 60, cat: 'assistant' })],
|
||||
}))
|
||||
assert.deepEqual(items.map(i => i.key), ['sys', 'tool:bash', 'n3', 'n20', 'n60'])
|
||||
})
|
||||
|
||||
test('zero-token items keep their band (the bar drops zero widths, the reading order stays truthful)', () => {
|
||||
const items = dnaOf(asm({ nodes: [node({ seq: 1, tokens: 0 }), node({ seq: 2 })] }))
|
||||
assert.deepEqual(items.map(i => i.key), ['n1', 'n2'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user