diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 45680fd..b723e4d 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -106,6 +106,9 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi const [hoverTurn, setHoverTurn] = React.useState(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(null) @@ -286,15 +289,27 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
{t('trend.title')} {t('trend.hint')} -
- - +
+
+ + +
+
+ + +
{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} diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index 6d4a2a8..fe80e6a 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -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) }) : '') } diff --git a/src/client/format.ts b/src/client/format.ts index cf69650..a96984d 100644 --- a/src/client/format.ts +++ b/src/client/format.ts @@ -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`). */ diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 55fb811..0711f32 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -35,7 +35,9 @@ export const DICT_ZH: Record = { '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 = { '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 = { '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 = { '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})', diff --git a/src/client/styles.css b/src/client/styles.css index 415b719..0610adf 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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); } diff --git a/src/shared/types.ts b/src/shared/types.ts index f3d520c..ed2d7c6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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). */ diff --git a/tests/composition-link.spec.ts b/tests/composition-link.spec.ts index 3db838e..5816d51 100644 --- a/tests/composition-link.spec.ts +++ b/tests/composition-link.spec.ts @@ -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') diff --git a/tests/real-react.client.spec.ts b/tests/real-react.client.spec.ts index 29f0102..b132e6f 100644 --- a/tests/real-react.client.spec.ts +++ b/tests/real-react.client.spec.ts @@ -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)) } diff --git a/tests/tool-chip-bridge.spec.ts b/tests/tool-chip-bridge.spec.ts index b90ba60..c048e58 100644 --- a/tests/tool-chip-bridge.spec.ts +++ b/tests/tool-chip-bridge.spec.ts @@ -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')