feat(client): focus the trend chart bars on the browser's open category

Expanding a category in the Context browser now plots every trend bar on
that category alone: the axis rescales to the category's own max (provider
anchor preserved) in both total and delta modes, the hover tip names the
category, and the card subtitle reads "Showing {cat} only" until the
category collapses. The /context modal keeps its unfocused chart.
This commit is contained in:
bowenliang123 committed 2026-09-07 13:45:41 +08:00
1 parent 1eb10e64e2
commit bcbbc3367f
7 files changed
+203 -13

No files matched your search

+19 -7
View File
@@ -51,6 +51,11 @@ export interface ContextBrowserProps {
onNodeFocusHandled?: () => void
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
/**
* Reports the open category (null once none opens): the Context tab focuses the trend chart's bars on it.
* Absent (the /context modal) — the accordion stays purely internal.
*/
onOpenCat?: (cat: string | null) => void
loadImage?: ImageLoader
/**
* The timeline source's detail state (split generation): while the first
@@ -584,6 +589,13 @@ export function makeContextBrowser(
// compares epochs.
const [rowQuery, setRowQuery] = useState('')
const [toolSort, setToolSort] = useState<'size' | 'name'>('size')
// 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
const setCat = (c: string | null): void => {
setOpenCat(c)
if (onOpenCat !== undefined) onOpenCat(c)
}
// Full message content: the conversation-window join first (zero cost),
// plus nodes fetched on demand for seqs outside the window (node arrays
@@ -620,9 +632,9 @@ export function makeContextBrowser(
const pinSeq = props.pinSeq
useEffect(() => {
setSel(pinSeq === null || pinSeq === undefined ? 'live' : pinSeq)
setOpenCat(null)
setCat(null)
setOpenElem(null)
}, [pinSeq])
}, [pinSeq, onOpenCat])
// Step-brief reveal: select the owning step, open the node's category + element (the pagination effect above already pulls older
// history for a missing join), then arm a one-shot scroll consumed by the layout effect once the row renders.
const rootRef = useRef<HTMLDivElement | null>(null)
@@ -631,11 +643,11 @@ export function makeContextBrowser(
useEffect(() => {
if (nodeFocus === null || nodeFocus === undefined) return
setSel(nodeFocus.step)
setOpenCat(nodeFocus.cat)
setCat(nodeFocus.cat)
setOpenElem('n' + String(nodeFocus.seq))
focusScrollRef.current = true
if (props.onNodeFocusHandled !== undefined) props.onNodeFocusHandled()
}, [nodeFocus, props.onNodeFocusHandled])
}, [nodeFocus, props.onNodeFocusHandled, onOpenCat])
useLayoutEffect(() => {
if (!focusScrollRef.current) return
focusScrollRef.current = false
@@ -681,7 +693,7 @@ export function makeContextBrowser(
const total = breakdown.total
const pick = (v: string) => {
setSel(v === 'live' ? 'live' : Number(v))
setOpenCat(null)
setCat(null)
setOpenElem(null)
}
@@ -718,11 +730,11 @@ export function makeContextBrowser(
|| ((c === 'system' || c === 'tools') && view.header === null)
if (!openable) return
if (openCat === c) {
setOpenCat(null)
setCat(null)
setOpenElem(null)
return
}
setOpenCat(c)
setCat(c)
// A different category opens unfiltered — the lens belongs to the open one.
setRowQuery('')
setOpenElem(singleKeyOf(c))
+5 -1
View File
@@ -105,6 +105,8 @@ export function makeContextView(
// turn-level pin below (mirrors the strip-click's consume-once focus flow).
const [jumpSeq, setJumpSeq] = useState<number | null>(null)
const [hoverCat, setHoverCat] = useState<string | null>(null)
// The browser's open category: the trend card focuses its bars on it (collapsing the category restores all).
const [focusCat, setFocusCat] = useState<string | null>(null)
const [pickedKinds, setPickedKinds] = useState<string[]>([...EVENT_KINDS])
const toggleKind = (k: string) => {
setPickedKinds((p) => {
@@ -368,7 +370,7 @@ export function makeContextView(
<div className="lc-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('trend.title')}</span>
<span className="lc-card-sub">{t('trend.hint')}</span>
<span className="lc-card-sub">{focusCat !== null ? t('trend.focus', { cat: kit.catLabel(focusCat) }) : t('trend.hint')}</span>
<div className="lc-trend-ctl">
<div className="lc-gran">
<button
@@ -415,6 +417,7 @@ export function makeContextView(
mode={trendMode}
focusTurn={focusTurn}
hoverCat={trendHoverCat}
focusCat={focusCat}
onSelect={setSelectedSeq}
onHover={setHoveredSeq}
onHoverTurn={setHoverTurn}
@@ -453,6 +456,7 @@ export function makeContextView(
pinSeq={pinnedReq !== null ? pinnedReq.seq : null}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
onOpenCat={setFocusCat}
nodeFocus={nodeFocus}
onNodeFocusHandled={clearNodeFocus}
loadImage={loadImage}
+35 -5
View File
@@ -4,7 +4,7 @@
*/
import { memo, useLayoutEffect, useMemo, useRef, type ReactElement, type UIEvent } from 'react'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import type { Category, ContextEventRecord, RequestRecord } from '../../shared/types'
import { CATS } from '../categories'
import type { ViewKit } from '../viewkit'
@@ -19,6 +19,12 @@ export interface TrendChartProps {
focusTurn: number | null
/** Mirrored category hover (shared with the overview and the browser): lights that category's segment in every bar. */
hoverCat: string | null
/**
* The browser's open category: every bar plots only that category (provider-anchored), with the axis rescaled
* to its own max. Null plots every category; an unrecognized key (a stale or hostile state) degrades to the
* unfocused chart.
*/
focusCat?: string | null
onSelect: (seq: number | null) => void
onHover: (seq: number | null) => void
onHoverTurn: (turn: number | null) => void
@@ -75,7 +81,7 @@ export function jumpTargetOf(requests: RequestRecord[], seq: number): RequestRec
}
export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactElement {
const { t, fmt, eventLabel, eventAt } = kit
const { t, fmt, eventLabel, eventAt, catLabel } = kit
const CHART_H = 112
// Quarter-mark label tops for the axis (mirrored to .lc-axis-q1/.lc-axis-q3 in trendChart.css): chart top 18
@@ -112,6 +118,20 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
const barTotalOf = (req: RequestRecord): number =>
typeof req.prompt === 'number' && req.prompt > 0 ? req.prompt : req.total
/**
* Focus a bar on one category (the browser's open category): the kept bucket carries its provider-anchored
* value, the other buckets zero, and the provider prompt drops — the downstream stack/anchor/tooltip math
* reads the derived record unchanged (`total` IS the plotted figure; the anchor becomes a no-op).
*/
const focusOf = (req: RequestRecord, cat: string): RequestRecord => {
const key = cat as Category | 'system' | 'tools'
const { prompt: _prompt, output: _output, ...out } = req
const v = Math.round((req[key] || 0) * anchorOf(req))
out.total = v
for (const c of CATS) out[c.key] = c.key === key ? v : 0
return out
}
/**
* Delta mode: each category keeps the SIGNED change vs the previous record so bars can diverge
* above/below the zero line; `total` is the churn (summed magnitude), `net` the signed change
@@ -212,9 +232,16 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
return function TrendChart(props: TrendChartProps): ReactElement {
const delta = props.mode === 'delta'
// An unrecognized focus key degrades to the unfocused chart instead of plotting an empty axis.
const focus = props.focusCat !== null && props.focusCat !== undefined && CATS.some(c => c.key === props.focusCat)
? props.focusCat
: null
const requests = useMemo(
() => (delta ? props.requests.map((req, i) => deltaOf(req, i > 0 ? props.requests[i - 1] : null)) : props.requests),
[props.requests, delta],
() => {
const base = focus !== null ? props.requests.map(req => focusOf(req, focus)) : props.requests
return delta ? base.map((req, i) => deltaOf(req, i > 0 ? base[i - 1] : null)) : base
},
[props.requests, delta, focus],
)
const markers = props.markers
let maxTotal = 1
@@ -400,7 +427,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
const n = req.net ?? 0
return [head, t('tip.delta', { n: (n > 0 ? '+' : '') + fmt(n) })]
}
return [head, t('tip.total', { n: fmt(barTotalOf(req)) })]
// Focused: the metric row IS the focused category's figure, so the tip names it instead of claiming a total.
return [head, focus !== null
? t('tip.cat', { cat: catLabel(focus), n: fmt(barTotalOf(req)) })
: t('tip.total', { n: fmt(barTotalOf(req)) })]
}
const hoveredIdx = props.hoveredSeq !== null ? requests.findIndex(r => r.seq === props.hoveredSeq) : -1
const hoveredReq = hoveredIdx >= 0 ? requests[hoveredIdx] : null
+4
View File
@@ -58,6 +58,7 @@ export const DICT_ZH: Record<string, string> = {
'gran.total': '全量', 'gran.delta': '增量',
'gran.modeHint': '全量:累计构成;增量:相对上一条请求的变化量',
'trend.hint': '✂ 表示压缩/剪枝,步骤/轮次 切换粒度',
'trend.focus': '仅展示 {cat}',
'trend.empty': '发起一轮对话后,这里会展示每次模型请求的上下文构成',
'detail.step': '第 {t} 轮 · 第 {s} 步',
'detail.turn': '第 {t} 轮 · 共 {n} 步',
@@ -149,6 +150,7 @@ export const DICT_ZH: Record<string, string> = {
'tip.turn': '第 {t} 轮 · 共 {n} 步',
'tip.turn1': '第 {t} 轮 · 共 1 步',
'tip.total': '合计 ≈ {n}',
'tip.cat': '{cat} ≈ {n}',
'tip.delta': 'Δ {n}',
'ev.compaction': '压缩上下文(摘要替换 {n} 条消息)',
'ev.prune': '剪枝工具输出',
@@ -279,6 +281,7 @@ export const DICT_EN: Record<string, string> = {
'gran.total': 'Total', 'gran.delta': 'Delta',
'gran.modeHint': 'Total: cumulative makeup; Delta: change vs the previous request',
'trend.hint': '✂ marks compaction/prune, Step/Turn switches granularity',
'trend.focus': 'Showing {cat} only',
'trend.empty': 'Send a message and each model request’s context makeup shows up here',
'detail.step': 'Turn {t} · Step {s}',
'detail.turn': 'Turn {t} · {n} steps',
@@ -370,6 +373,7 @@ export const DICT_EN: Record<string, string> = {
'tip.turn': 'Turn {t} · {n} steps',
'tip.turn1': 'Turn {t} · 1 step',
'tip.total': 'Total ≈ {n}',
'tip.cat': '{cat} ≈ {n}',
'tip.delta': 'Δ {n}',
'ev.compaction': 'Context compacted (summary replaced {n} messages)',
'ev.prune': 'Tool output pruned',
+42
View File
@@ -1504,3 +1504,45 @@ describe('ContextBrowser — the split generation detail states', () => {
await m.unmount()
})
})
describe('ContextBrowser open-category reporting', () => {
test('toggling categories and step picks report the open category outward', async () => {
const data = tl({
requests: [req({ seq: 10, turn: 1, step: 0 }), req({ seq: 20, turn: 2, step: 0 })],
nodes: [
node({ seq: 1, cat: 'user', text: 'hi' }),
node({ seq: 2, cat: 'user', text: 'again' }),
node({ seq: 3, cat: 'tool', tokens: 30, tool: 'bash', text: 'out' }),
],
})
const opens: (string | null)[] = []
const onOpenCat = (c: string | null): void => { opens.push(c) }
const m = await mount(h(Browser, props({ data, onOpenCat })))
assert.deepEqual(opens, [null], 'the mount-time pin pass reports the closed accordion')
await click(catRow(m, 'user'))
assert.deepEqual(opens, [null, 'user'])
await click(catRow(m, 'user'))
assert.deepEqual(opens, [null, 'user', null])
await click(catRow(m, 'tool'))
assert.deepEqual(opens, [null, 'user', null, 'tool'])
await pickStep(m, '20')
assert.deepEqual(opens, [null, 'user', null, 'tool', null], 'a step pick closes the open category')
await m.unmount()
})
test('a pin change and a brief reveal report their reset / target category', async () => {
const data = tl({ requests: [req({ seq: 10, turn: 1, step: 0 })], nodes: [node({ seq: 1, cat: 'user', text: 'hi' })] })
const opens: (string | null)[] = []
const onOpenCat = (c: string | null): void => { opens.push(c) }
const m = await mount(h(Browser, props({ data, onOpenCat })))
await m.update(h(Browser, props({ data, onOpenCat, pinSeq: 10 })))
assert.deepEqual(opens, [null, null], 'pinning resets the accordion')
await m.update(h(Browser, props({
data, onOpenCat, pinSeq: 10,
nodeFocus: { step: 'live', seq: 1, cat: 'tool' },
onNodeFocusHandled: () => {},
})))
assert.deepEqual(opens, [null, null, 'tool'], 'the reveal opens the node category')
await m.unmount()
})
})
@@ -385,6 +385,30 @@ describe('ContextView — interactions', () => {
await m.unmount()
})
test('expanding a browser category focuses the trend bars on it; collapsing restores all', async () => {
const m = await mountRich('sv-focus')
assert.equal(text(query(m.container, '.lc-axis-top')), '420', 'unfocused: the axis tops at the largest whole-bar total')
// Five segments on the bar: richTimeline carries no injects, so inject renders none.
assert.equal(queryAll(m.container, '.lc-bar[data-seq="4"] .lc-bar-stack > .lc-cat-seg').length, 5)
// Expanding the browser's assistant category focuses every bar on it — one segment per bar, the axis
// rescaled to the category's own max (20/60/80; the first rides its provider-prompt anchor to 21).
await click(queryAll(m.container, '.lc-br-cat-row')[4])
assert.equal(text(query(m.container, '.lc-axis-top')), '80')
const segs = queryAll(m.container, '.lc-bar .lc-bar-stack > .lc-cat-seg')
assert.equal(segs.length, 3)
for (const s of segs) assert.equal(s.getAttribute('data-cat'), 'assistant')
assert.ok(text(m.container).includes(DICT_EN['trend.focus'].replace('{cat}', DICT_EN['cat.assistant'])),
'the card subtitle names the focused category')
// Collapsing the category restores the whole composition and the usage hint.
await click(queryAll(m.container, '.lc-br-cat-row')[4])
assert.equal(text(query(m.container, '.lc-axis-top')), '420')
assert.equal(queryAll(m.container, '.lc-bar[data-seq="4"] .lc-bar-stack > .lc-cat-seg').length, 5)
assert.ok(text(m.container).includes(DICT_EN['trend.hint']))
await m.unmount()
})
test('delta mode pairs the detail with the previous record; first bar has none', async () => {
const m = await mountRich('sv-delta')
const chart = query(m.container, '.lc-chart')
@@ -466,6 +466,80 @@ describe('TrendChart category hover-link', () => {
})
})
describe('TrendChart category focus (the browser open category)', () => {
const r1 = req(1, { turn: 1, step: 0 })
const r2 = req(2, { turn: 1, step: 1, system: 200, tools: 100, user: 60, inject: 40, assistant: 80, tool: 120, total: 600 })
test('total mode plots only the focused category, rescaled to its own max, and the tip names it', async () => {
const m = await mount(h(TrendChart, propsOf([r1, r2], { focusCat: 'tool' })))
// maxTotal follows the focused figure (60 / 120), not the bars' whole compositions (300 / 600).
assert.equal(query(m.container, '.lc-axis-top').textContent, '120')
const bs = bars(m.container)
const segs1 = queryAll(bs[0], '.lc-bar-stack > div')
assert.equal(segs1.length, 1)
assert.equal(segs1[0].getAttribute('data-cat'), 'tool')
assertColor(segs1[0].style.background, CATS[5].color)
assert.equal(segs1[0].style.height, `${Math.round(60 / 120 * CHART_H)}px`)
assert.equal(queryAll(bs[1], '.lc-bar-stack > div')[0].style.height, `${CHART_H}px`)
// The tooltip's metric row names the focused category and carries its own figure.
await m.update(h(TrendChart, propsOf([r1, r2], { focusCat: 'tool', hoveredSeq: 1 })))
assert.deepEqual(
queryAll(query(m.container, '.lc-chart-tip'), 'span').map(r => r.textContent),
[kit.t('tip.step', { t: 1, s: 0 }), kit.t('tip.cat', { cat: kit.catLabel('tool'), n: '60' })],
)
await m.unmount()
})
test('the provider anchor rides the focused figure; a category at zero everywhere keeps the unit axis', async () => {
// r1 anchors 600/300 = 2 → its focused tool value rides 120 (unanchored it would stay 60).
const r1a = req(1, { turn: 1, step: 0, prompt: 600 })
const m = await mount(h(TrendChart, propsOf([r1a, r2], { focusCat: 'tool' })))
assert.equal(query(m.container, '.lc-axis-top').textContent, '120')
assert.equal(queryAll(bars(m.container)[0], '.lc-bar-stack > div')[0].style.height, `${CHART_H}px`)
await m.unmount()
const zero = req(9, { turn: 1, step: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 })
const mz = await mount(h(TrendChart, propsOf([zero], { focusCat: 'user' })))
assert.equal(query(mz.container, '.lc-axis-top').textContent, '1')
assert.equal(queryAll(bars(mz.container)[0], '.lc-bar-stack > div').length, 0)
await mz.unmount()
})
test('delta mode diffs the focused category only', async () => {
const base = req(1, { turn: 1, step: 0 })
const grown = req(2, { turn: 1, step: 1, system: 130, tools: 50, user: 30, inject: 20, assistant: 40, tool: 60, total: 330 })
const shrunk = req(3, { turn: 2, step: 0, system: 90, tools: 50, user: 30, inject: 20, assistant: 40, tool: 60, total: 290 })
const m = await mount(h(TrendChart, propsOf([base, grown, shrunk], { mode: 'delta', focusCat: 'system' })))
// Only the system deltas (+30 / -40) drive the scale: maxUp 30, maxDown 40.
assert.equal(query(m.container, '.lc-axis-top').textContent, '+30')
assert.equal(query(m.container, '.lc-axis-bot').textContent, '-40')
const bs = bars(m.container)
assert.equal(queryAll(bs[0], '.lc-bar-up > div').length, 0)
const up = queryAll(bs[1], '.lc-bar-up > div')
assert.equal(up.length, 1)
assert.equal(up[0].getAttribute('data-cat'), 'system')
assert.equal(up[0].style.height, `${Math.round(30 * CHART_H / 70)}px`)
const down = queryAll(bs[2], '.lc-bar-down > div')
assert.equal(down.length, 1)
assert.equal(down[0].style.height, `${Math.round(40 * CHART_H / 70)}px`)
// The delta tip reports the focused category's signed change.
await m.update(h(TrendChart, propsOf([base, grown, shrunk], { mode: 'delta', focusCat: 'system', hoveredSeq: 2 })))
assert.ok(query(m.container, '.lc-chart-tip').textContent!.includes(kit.t('tip.delta', { n: '+30' })))
await m.unmount()
})
test('an unknown focus key and an explicit null degrade to the unfocused chart', async () => {
for (const focusCat of ['nope', null]) {
const m = await mount(h(TrendChart, propsOf([r1, r2], { focusCat })))
assert.equal(queryAll(bars(m.container)[0], '.lc-bar-stack > div').length, 6)
assert.equal(query(m.container, '.lc-axis-top').textContent, '600')
await m.unmount()
}
})
})
describe('TrendChart markers', () => {
test('compaction/prune markers render the ✂ glyph with a positioned or bare title', async () => {
const reqs = [req(1, { turn: 1, step: 0 }), req(2, { turn: 1, step: 1 }), req(3, { turn: 2, step: 0 })]