feat(client): add Total/Diff display mode to the Context History chart

Adds a display-mode pair (全量/增量, Total/Diff) next to the Step/Turn granularity group in the Context History card. Diff mode plots each request's per-category |change| vs the previous request, with the hover tip reporting the signed net change (Δ +1.2k / Δ −535.5k); Total keeps the cumulative composition and stays the default.

Original PR by @xieshuaix (delta toggle); reworked before merge: the on/off delta switch became a two-way mode control (mode: 'total' | 'diff' end to end), the two toggle groups were wrapped so the title's auto-margin push happens once, trend.hint was shortened/localized, and the fiber-slot/button-count tests were updated.
This commit is contained in:
sxgitmiao authored and GitHub committed 2026-08-23 22:49:09 +08:00
1 parent 095e2f525e
commit be751da48d
9 files changed
+106 -27

No files matched your search

+25 -9
View File
@@ -106,6 +106,9 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
const [hoverTurn, setHoverTurn] = React.useState<number | null>(null)
const [tick, setTick] = React.useState(0)
const [granularity, setGranularity] = React.useState<'step' | 'turn'>('step')
// Trend display mode: 'total' plots each request's cumulative
// composition, 'diff' plots its incremental change vs the previous one.
const [trendMode, setTrendMode] = React.useState<'total' | 'diff'>('total')
// Turn strip click target: the chart switches to turn granularity and
// scroll-centers this turn's bar, then clears it via onFocusTurnHandled.
const [focusTurn, setFocusTurn] = React.useState<number | null>(null)
@@ -286,15 +289,27 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
<div className="lc-card-title">
<span className="lc-card-title-text">{t('trend.title')}</span>
<span className="lc-card-sub">{t('trend.hint')}</span>
<div className="lc-gran">
<button
className={'lc-gran-btn' + (granularity === 'step' ? ' lc-gran-on' : '')}
onClick={() => { setGranularity('step') }}
>{t('gran.step')}</button>
<button
className={'lc-gran-btn' + (granularity === 'turn' ? ' lc-gran-on' : '')}
onClick={() => { setGranularity('turn') }}
>{t('gran.turn')}</button>
<div className="lc-trend-ctl">
<div className="lc-gran">
<button
className={'lc-gran-btn' + (granularity === 'step' ? ' lc-gran-on' : '')}
onClick={() => { setGranularity('step') }}
>{t('gran.step')}</button>
<button
className={'lc-gran-btn' + (granularity === 'turn' ? ' lc-gran-on' : '')}
onClick={() => { setGranularity('turn') }}
>{t('gran.turn')}</button>
</div>
<div className="lc-gran" title={t('gran.modeHint')}>
<button
className={'lc-gran-btn' + (trendMode === 'total' ? ' lc-gran-on' : '')}
onClick={() => { setTrendMode('total') }}
>{t('gran.total')}</button>
<button
className={'lc-gran-btn' + (trendMode === 'diff' ? ' lc-gran-on' : '')}
onClick={() => { setTrendMode('diff') }}
>{t('gran.diff')}</button>
</div>
</div>
</div>
{displayRequests.length === 0
@@ -314,6 +329,7 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
hoveredSeq={hoveredSeq}
activeTurn={activeTurn}
granularity={granularity}
mode={trendMode}
focusTurn={focusTurn}
onSelect={setSelectedSeq}
onHover={setHoveredSeq}
+43 -1
View File
@@ -24,6 +24,8 @@ export interface TrendChartProps {
activeTurn: number | null
/** Display granularity; a switch re-anchors the chart at the newest bars. */
granularity: 'step' | 'turn'
/** Display mode: cumulative totals ('total') or per-request incremental diff ('diff'). */
mode: 'total' | 'diff'
/** A strip-clicked turn pending scroll-centering; null once handled. */
focusTurn: number | null
onSelect: (seq: number | null) => void
@@ -103,6 +105,33 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
const barTotalOf = (req: RequestRecord): number =>
typeof req.prompt === 'number' && req.prompt > 0 ? req.prompt : req.total
/**
* Transform one request into its per-request incremental diff for the
* diff mode. Each category becomes |current − previous| (the magnitude of
* change, stacked like the cumulative bars), `total` becomes the summed
* magnitude, and `net` keeps the signed change for the tooltip. The first
* request (no previous) diff from empty is zero, so the diff mode's scale
* is driven purely by inter-request changes rather than the initial
* cumulative baseline. Provider prompt/output are dropped: they are
* per-request values, not diffs.
*/
const diffOf = (req: RequestRecord, prev: RequestRecord | null): RequestRecord => {
const out = { ...req }
let churn = 0
let net = 0
for (const c of CATS) {
const d = prev !== null ? (req[c.key] || 0) - (prev[c.key] || 0) : 0
out[c.key] = Math.abs(d)
churn += Math.abs(d)
net += d
}
out.total = churn
out.net = net
delete out.prompt
delete out.output
return out
}
interface ChartBarProps {
req: RequestRecord
/** The boundary event attached to this bar, if any. */
@@ -159,7 +188,14 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
})
return function TrendChart(props: TrendChartProps): ReactNS.ReactElement {
const requests = props.requests
const diff = props.mode === 'diff'
// Diff mode swaps the cumulative records for per-request diffs. Memoized
// so hover-driven re-renders keep the bar props identity-stable (same
// reason the parent memoizes its turn aggregation).
const requests = React.useMemo(
() => (diff ? props.requests.map((req, i) => diffOf(req, i > 0 ? props.requests[i - 1] : null)) : props.requests),
[props.requests, diff],
)
const markers = props.markers
let maxTotal = 1
for (const req of requests) {
@@ -245,6 +281,12 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
const head = req.stepCount !== undefined && req.stepCount > 1
? t('tip.turn', { t: req.turn ?? 0, n: req.stepCount })
: t('tip.step', { t: req.turn ?? 0, s: req.step ?? 0 })
if (diff) {
// Diff mode: the bar height is the summed magnitude, but the hover
// tip reports the SIGNED net change so growth vs shrink is explicit.
const n = req.net ?? 0
return head + ' · ' + fmtTime(req.time) + ' · ' + t('tip.diff', { n: (n > 0 ? '+' : '') + fmt(n) })
}
return head + ' · ' + fmtTime(req.time) + ' · ' + t('tip.total', { n: fmt(req.total) })
+ (req.prompt !== undefined ? ' · ' + t('tip.actual', { n: fmt(req.prompt) }) : '')
}
+5 -3
View File
@@ -5,9 +5,11 @@
export function fmt(n: number | null | undefined): string {
if (n === undefined || n === null || isNaN(n)) return '—'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(Math.round(n))
const sign = n < 0 ? '-' : ''
const a = Math.abs(n)
if (a >= 1e6) return sign + (a / 1e6).toFixed(1) + 'M'
if (a >= 1000) return sign + (a / 1000).toFixed(1) + 'k'
return sign + String(Math.round(a))
}
/** Byte sizes for attachment metadata (1 kB = 1000 B, matching the k/M style of `fmt`). */
+8 -2
View File
@@ -35,7 +35,9 @@ export const DICT_ZH: Record<string, string> = {
'tools.more': '等 {n} 个',
'trend.title': '历史趋势',
'gran.step': '步骤', 'gran.turn': '轮次',
'trend.hint': '悬停查看详情,✂ 表示压缩/剪枝,Step/Turn 切换粒度',
'gran.total': '全量', 'gran.diff': '增量',
'gran.modeHint': '全量:累计构成;增量:相对上一条请求的变化量',
'trend.hint': '✂ 表示压缩/剪枝,步骤/轮次 切换粒度',
'trend.empty': '发起一轮对话后,这里会展示每次模型请求的上下文构成',
'detail.step': '第 {t} 轮 · 第 {s} 步',
'detail.turn': '第 {t} 轮 · 共 {n} 步',
@@ -61,6 +63,7 @@ export const DICT_ZH: Record<string, string> = {
'tip.turn': '第 {t} 轮 · 共 {n} 步',
'tip.total': '合计 ≈ {n}',
'tip.actual': '(实际 {n})',
'tip.diff': 'Δ {n}',
'ev.compaction': '压缩上下文(摘要替换 {n} 条消息)',
'ev.prune': '剪枝工具输出',
'ev.skill': 'Skill 注入({name})',
@@ -153,7 +156,9 @@ export const DICT_EN: Record<string, string> = {
'tools.more': 'of {n}',
'trend.title': 'Context History',
'gran.step': 'Step', 'gran.turn': 'Turn',
'trend.hint': 'Hover to view details, ✂ marks compaction/prune, Step/Turn switches granularity',
'gran.total': 'Total', 'gran.diff': 'Diff',
'gran.modeHint': 'Total: cumulative makeup; Diff: change vs the previous request',
'trend.hint': '✂ marks compaction/prune, Step/Turn switches granularity',
'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',
@@ -179,6 +184,7 @@ export const DICT_EN: Record<string, string> = {
'tip.turn': 'Turn {t} · {n} steps',
'tip.total': 'Total ≈ {n}',
'tip.actual': ' (actual {n})',
'tip.diff': 'Δ {n}',
'ev.compaction': 'Context compacted (summary replaced {n} messages)',
'ev.prune': 'Tool output pruned',
'ev.skill': 'Skill injected ({name})',
+5
View File
@@ -36,6 +36,11 @@
.lc-stat-tip-model { color: var(--dsw-alias-label-primary); }
.lc-card-sub { font-weight: 400; color: var(--dsw-alias-label-secondary); font-size: 12px; }
.lc-gran, .lc-kinds { margin-left: auto; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 1px; }
/* A title carrying TWO toggle groups wraps them so the auto-margin push
happens once — two bare .lc-gran siblings would split the free space
between their auto margins and drift apart. */
.lc-trend-ctl { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.lc-trend-ctl .lc-gran { margin-left: 0; }
.lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; }
.lc-gran-btn:hover { color: var(--dsw-alias-label-primary); }
.lc-gran-on, .lc-gran-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
+6
View File
@@ -259,6 +259,12 @@ export interface RequestRecord {
* per turn shows its LAST step's record). The Host never sets it.
*/
stepCount?: number
/**
* Diff-mode signed net change, set by the Client's diffOf (only present
* on the diff-transformed records the TrendChart plots). The Host never
* sets it.
*/
net?: number
}
/** A notable context event (compaction, prune, injection, model switch). */
+5 -5
View File
@@ -41,7 +41,7 @@ test('current-composition hover link: browser rows + composition bar <-> overvie
assert.equal(typeof liveUserRow.args[1].onMouseEnter, 'function', 'live browser rows carry the hover link')
liveUserRow.args[1].onMouseEnter()
tr = renderView()
assert.equal(ctxSlots[6][0], 'user', 'browser row hover updates the shared hover category')
assert.equal(ctxSlots[7][0], 'user', 'browser row hover updates the shared hover category')
assert.equal(byClass(tr, 'lc-br-cat-on').length, 1, 'browser row hover links its own row')
assert.equal(barSegsOn(brStack()).length, 1, 'browser row hover lights the browser bar segment')
assert.equal(barSegsOn(brStack())[0].args[1].key, 'user', 'the lit browser segment is user')
@@ -50,7 +50,7 @@ test('current-composition hover link: browser rows + composition bar <-> overvie
assert.match(textOf(byClass(tr, 'lc-chip-on')[0]), /User/, 'the lit chip is the user category')
liveUserRow.args[1].onMouseLeave()
tr = renderView()
assert.equal(ctxSlots[6][0], null, 'leaving the browser row clears the shared hover')
assert.equal(ctxSlots[7][0], null, 'leaving the browser row clears the shared hover')
assert.equal(byClass(tr, 'lc-br-cat-on').length, 0, 'the row echo clears with the leave')
assert.equal(byClass(tr, 'lc-stacked-seg-on').length, 0, 'both bars clear with the row leave')
assert.equal(byClass(tr, 'lc-chip-on').length, 0, 'the legend chip clears with the row leave')
@@ -73,13 +73,13 @@ test('current-composition hover link: browser rows + composition bar <-> overvie
// browser composition bar -> overview (the browser's bart joins the link)
segOf(brStack(), 'user').args[1].onMouseEnter()
tr = renderView()
assert.equal(ctxSlots[6][0], 'user', 'browser bar hover updates the shared hover category')
assert.equal(ctxSlots[7][0], 'user', 'browser bar hover updates the shared hover category')
assert.equal(barSegsOn(ovrStack()).length, 1, 'browser bar hover lights the overview segment')
assert.equal(byClass(tr, 'lc-chip-on').length, 1, 'browser bar hover lights the overview legend chip')
assert.equal(byClass(tr, 'lc-br-cat-on').length, 1, 'browser bar hover links the browser category row too')
brStack().args[1].onMouseLeave()
tr = renderView()
assert.equal(ctxSlots[6][0], null, 'leaving the browser bar clears the shared hover')
assert.equal(ctxSlots[7][0], null, 'leaving the browser bar clears the shared hover')
// pinned/previewed step: the compositions differ, so no linkage either way
ctxSlots[1][1](2) // trend hover previews step 2 in the browser
@@ -88,7 +88,7 @@ test('current-composition hover link: browser rows + composition bar <-> overvie
assert.equal(catRowOf(tr, 'User').args[1].onMouseEnter, undefined, 'pinned browser rows carry no hover link')
segOf(brStack(), 'user').args[1].onMouseEnter() // handler exists; the live gate must make it a no-op
tr = renderView()
assert.equal(ctxSlots[6][0], null, 'pinned browser bar hover does not leak into the overview')
assert.equal(ctxSlots[7][0], null, 'pinned browser bar hover does not leak into the overview')
segOf(ovrStack(), 'assistant').args[1].onMouseEnter({ clientX: 80 })
tr = renderView()
assert.equal(byClass(tr, 'lc-br-cat-on').length, 0, 'overview hover does not echo into a pinned step')
+4 -2
View File
@@ -135,13 +135,15 @@ test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', a
const scroller = container.querySelector('.lc-chart-scroll')
scroller.__clientW = 800
const buttons = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
assert.equal(buttons.length, 2, 'toggle buttons rendered')
// Two toggle groups: granularity (Step/Turn) + display mode (Total/Diff);
// the first two buttons are the granularity pair exercised below.
assert.equal(buttons.length, 4, 'toggle buttons rendered')
// Rapid toggles: the overflow state flips each time (wide step chart vs
// narrow turn chart), which used to cascade setEdges dispatches inside the
// no-deps layout effect until React #185 (maximum update depth) fired.
for (let i = 0; i < 8; i++) {
const btns = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
assert.equal(btns.length, 2, `toggle buttons still mounted after ${i} switches`)
assert.equal(btns.length, 4, `toggle buttons still mounted after ${i} switches`)
btns[i % 2].click()
await new Promise(r => setTimeout(r, 20))
}
+5 -5
View File
@@ -44,10 +44,10 @@ test('overview tool-chip bridge: label opens the tools category, chip expands th
// Clicking the label opens the tools category only (no specific tool).
toolsLabel.args[1].onClick()
tr = renderView()
assert.deepEqual(ctxSlots[8][0], {}, 'label click records a category-only focus')
assert.deepEqual(ctxSlots[9][0], {}, 'label click records a category-only focus')
brSlots[9].effect() // the tool-bridge effect applies the one-shot request
tr = renderView()
assert.equal(ctxSlots[8][0], null, 'one-shot focus is cleared once applied')
assert.equal(ctxSlots[9][0], null, 'one-shot focus is cleared once applied')
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'focus switches the browser to the live step')
assert.equal(byClass(tr, 'lc-br-body').length, 1, 'tools category opens in the browser')
assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /bash/, 'tools category lists the schema rows')
@@ -55,10 +55,10 @@ test('overview tool-chip bridge: label opens the tools category, chip expands th
// Clicking a specific tool also expands that tool's row.
chips = byClass(tr, 'lc-tool-chip')[0].args[1].onClick()
tr = renderView()
assert.deepEqual(ctxSlots[8][0], { tool: 'bash' }, 'chip click records a specific-tool focus')
assert.deepEqual(ctxSlots[9][0], { tool: 'bash' }, 'chip click records a specific-tool focus')
brSlots[9].effect()
tr = renderView()
assert.equal(ctxSlots[8][0], null, 'tool focus cleared once applied')
assert.equal(ctxSlots[9][0], null, 'tool focus cleared once applied')
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'stays on the live step')
const bridgeContent = byClass(tr, 'lc-br-content')
assert.equal(bridgeContent.length, 1, 'the clicked tool row expands open')
@@ -77,7 +77,7 @@ test('overview tool-chip bridge: label opens the tools category, chip expands th
assert.equal(typeof moreBtn.args[1].onClick, 'function', 'the overflow link is clickable')
moreBtn.args[1].onClick()
tr = renderView()
assert.deepEqual(ctxSlots[8][0], {}, 'overflow link opens the tools category (no specific tool)')
assert.deepEqual(ctxSlots[9][0], {}, 'overflow link opens the tools category (no specific tool)')
brSlots[9].effect()
tr = renderView()
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'stays on the live step')