feat(trend): make delta mode signed end to end
Delta mode previously took per-category magnitudes for the bars while the detail panel below still showed cumulative totals. Now: - The chart draws diverging stacks: growth piles up from a solid zero baseline, shrinkage hangs down from it, with the zero line splitting the bar area proportionally so the px-per-token scale is identical on both sides. Axis reads +max / 0 / -max. - The detail panel pairs the active request with the previous displayed record (same pairing as the chart): a signed net chip replaces the provider usage pills, category rows show signed figures on the success/error tokens, and each row's mini bar diverges from a center zero hairline, echoing the chart.
This commit is contained in:
1 parent
9938fb7cef
commit
efcdd37cb7
6 files changed
+236
-37
No files matched your search
@@ -172,14 +172,16 @@ export function makeContextView(
|
||||
return i >= 0 ? markers[i] : undefined
|
||||
}
|
||||
|
||||
let pinnedReq: RequestRecord | null = null
|
||||
for (const req of displayRequests) if (req.seq === selectedSeq) pinnedReq = req
|
||||
let activeReq: RequestRecord | null = null
|
||||
let pinnedIdx = -1
|
||||
for (let i = 0; i < displayRequests.length; i++) if (displayRequests[i].seq === selectedSeq) pinnedIdx = i
|
||||
const pinnedReq = pinnedIdx >= 0 ? displayRequests[pinnedIdx] : null
|
||||
let activeIdx = -1
|
||||
if (hoveredSeq !== null) {
|
||||
for (const req of displayRequests) if (req.seq === hoveredSeq) activeReq = req
|
||||
for (let i = 0; i < displayRequests.length; i++) if (displayRequests[i].seq === hoveredSeq) { activeIdx = i; break }
|
||||
}
|
||||
if (activeReq === null) activeReq = pinnedReq
|
||||
if (activeReq === null && displayRequests.length > 0) activeReq = displayRequests[displayRequests.length - 1]
|
||||
if (activeIdx < 0) activeIdx = pinnedIdx
|
||||
if (activeIdx < 0 && displayRequests.length > 0) activeIdx = displayRequests.length - 1
|
||||
const activeReq = activeIdx >= 0 ? displayRequests[activeIdx] : null
|
||||
|
||||
// Turn highlight is hover-only: the turn strip hover wins, then the hovered bar's turn — no fallback, so a pinned or default selection
|
||||
// never keeps a turn glowing.
|
||||
@@ -271,7 +273,12 @@ export function makeContextView(
|
||||
onPickTurn={(turn) => { setGranularity('turn'); setFocusTurn(turn) }}
|
||||
onFocusTurnHandled={() => { setFocusTurn(null) }}
|
||||
/>
|
||||
<RequestDetail request={activeReq} marker={activeReq !== null ? markerOf(activeReq) : undefined} />
|
||||
<RequestDetail
|
||||
request={activeReq}
|
||||
// Delta mode pairs the detail with the SAME previous record the chart diffs against (first bar: null).
|
||||
prev={trendMode === 'delta' && activeIdx >= 0 ? (activeIdx > 0 ? displayRequests[activeIdx - 1] : null) : undefined}
|
||||
marker={activeReq !== null ? markerOf(activeReq) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,13 @@ import { React } from '../react'
|
||||
|
||||
export interface RequestDetailProps {
|
||||
request: RequestRecord | null
|
||||
/**
|
||||
* Delta mode: the record displayed just before this one (null on the first
|
||||
* bar). Its PRESENCE switches the panel from cumulative makeup to the signed
|
||||
* change against that previous record — the same pairing the chart's
|
||||
* deltaOf plots.
|
||||
*/
|
||||
prev?: RequestRecord | null
|
||||
marker?: ContextEventRecord | null
|
||||
}
|
||||
|
||||
@@ -29,6 +36,23 @@ export function makeRequestDetail(
|
||||
// before and the request after (e.g. "✂ Turn 49 · Step 2→3").
|
||||
const marker = props.marker ?? null
|
||||
const markerAt = marker !== null ? eventAt(marker) : null
|
||||
// Delta mode: per-category SIGNED change vs the previous record (the chart stacks them diverging
|
||||
// above/below its zero line); provider usage chips drop out — prompt/output/cacheRead are
|
||||
// per-request figures, not deltas.
|
||||
const delta = props.prev !== undefined
|
||||
const prev = props.prev ?? null
|
||||
const deltas = CATS.map(c => delta ? (req[c.key] || 0) - (prev !== null ? prev[c.key] || 0 : 0) : 0)
|
||||
let net = 0
|
||||
let maxAbs = 0
|
||||
if (delta) {
|
||||
for (const d of deltas) {
|
||||
net += d
|
||||
if (Math.abs(d) > maxAbs) maxAbs = Math.abs(d)
|
||||
}
|
||||
}
|
||||
const parts = delta
|
||||
? CATS.map((c, i) => ({ key: c.key, color: c.color, value: Math.abs(deltas[i]) }))
|
||||
: partsOf(req)
|
||||
return (
|
||||
<div className="lc-detail">
|
||||
<div className="lc-detail-head">
|
||||
@@ -37,32 +61,59 @@ export function makeRequestDetail(
|
||||
? <span className="lc-detail-marker" title={eventLabel(marker)}>{'✂ ' + markerAt}</span>
|
||||
: null}
|
||||
{isTurn ? <span className="lc-detail-tag">{t('detail.lastStep')}</span> : null}
|
||||
{delta ? <span className="lc-detail-tag">{t('gran.delta')}</span> : null}
|
||||
<span className="lc-detail-time">{fmtTime(req.time)}</span>
|
||||
{/* Metric chips: one neutral pill per provider figure; the cache figure drops out on hosts
|
||||
that do not fold `cacheRead` (and on usage-less requests). */}
|
||||
{req.prompt !== undefined
|
||||
{delta ? (
|
||||
<span className={'lc-detail-metric' + (net > 0 ? ' lc-detail-metric-up' : net < 0 ? ' lc-detail-metric-down' : '')}>
|
||||
{t('tip.delta', { n: (net > 0 ? '+' : '') + fmt(net) })}
|
||||
</span>
|
||||
) : null}
|
||||
{!delta && req.prompt !== undefined
|
||||
? <span className="lc-detail-metric">{t('detail.actual', { n: fmt(req.prompt) })}</span>
|
||||
: null}
|
||||
{req.output !== undefined
|
||||
{!delta && req.output !== undefined
|
||||
? <span className="lc-detail-metric">{t('detail.output', { n: fmt(req.output) })}</span>
|
||||
: null}
|
||||
{req.prompt !== undefined && req.cacheRead !== undefined
|
||||
{!delta && req.prompt !== undefined && req.cacheRead !== undefined
|
||||
? <span className="lc-detail-metric">{t('detail.cache', { n: cacheHitPercent(req.cacheRead, req.prompt) ?? '—' })}</span>
|
||||
: null}
|
||||
</div>
|
||||
<StackedBar parts={partsOf(req)} height={10} />
|
||||
<StackedBar parts={parts} height={10} />
|
||||
<div className="lc-detail-rows">
|
||||
{CATS.map((c) => {
|
||||
const v = req[c.key] || 0
|
||||
{CATS.map((c, i) => {
|
||||
const v = delta ? deltas[i] : req[c.key] || 0
|
||||
const mag = Math.abs(v)
|
||||
return (
|
||||
<div key={c.key} className="lc-detail-row">
|
||||
<i style={{ background: c.color }} />
|
||||
<span className="lc-detail-label">{catLabel(c.key)}</span>
|
||||
<span className="lc-bar-track">
|
||||
<span className="lc-bar-fill" style={{ width: `${req.total > 0 ? v / req.total * 100 : 0}%`, background: c.color }} />
|
||||
{delta ? (
|
||||
<>
|
||||
{/* Mini diverging bar echoing the chart: zero at the middle, growth fills right,
|
||||
shrinkage fills left, one shared scale across the rows. */}
|
||||
<span className="lc-bar-zero" />
|
||||
{v !== 0 ? (
|
||||
<span
|
||||
className={'lc-bar-fill ' + (v > 0 ? 'lc-bar-fill-up' : 'lc-bar-fill-down')}
|
||||
style={{ width: `${mag / maxAbs * 50}%`, background: c.color }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="lc-bar-fill" style={{ width: `${req.total > 0 ? v / req.total * 100 : 0}%`, background: c.color }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="lc-detail-num">{'≈' + fmt(v)}</span>
|
||||
<span className="lc-detail-pct">{req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''}</span>
|
||||
{delta ? (
|
||||
<span className={'lc-detail-num' + (v > 0 ? ' lc-detail-num-up' : v < 0 ? ' lc-detail-num-down' : '')}>
|
||||
{(v > 0 ? '+' : '') + fmt(v)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="lc-detail-num">{'≈' + fmt(v)}</span>
|
||||
)}
|
||||
<span className="lc-detail-pct">{!delta && req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -84,9 +84,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
typeof req.prompt === 'number' && req.prompt > 0 ? req.prompt : req.total
|
||||
|
||||
/**
|
||||
* Delta mode: each category becomes |current − previous|, `total` the summed magnitude, `net` the signed change
|
||||
* for the tooltip; the first request starts from zero so the scale is change-driven, and per-request provider
|
||||
* prompt/output are dropped (they are not deltas).
|
||||
* 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
|
||||
* for the tooltip; the first request starts from zero so the scale is change-driven, and per-request
|
||||
* provider prompt/output are dropped (they are not deltas).
|
||||
*/
|
||||
const deltaOf = (req: RequestRecord, prev: RequestRecord | null): RequestRecord => {
|
||||
const out = { ...req }
|
||||
@@ -94,7 +95,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
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)
|
||||
out[c.key] = d
|
||||
churn += Math.abs(d)
|
||||
net += d
|
||||
}
|
||||
@@ -112,6 +113,15 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
hovered: boolean
|
||||
inTurn: boolean
|
||||
maxTotal: number
|
||||
/**
|
||||
* Delta mode geometry: zero-line offsets in px (up from the top / down from the bottom of the bar area)
|
||||
* and the uniform px-per-token scale — identical above and below the zero line, so a +n segment and a
|
||||
* −n segment always draw the same height. All three absent in total mode; passed as PRIMITIVES so the
|
||||
* memoized bar keeps its shallow-compare bailout.
|
||||
*/
|
||||
upPx?: number
|
||||
downPx?: number
|
||||
deltaScale?: number
|
||||
onSelect: (seq: number | null) => void
|
||||
onHover: (seq: number | null) => void
|
||||
}
|
||||
@@ -122,6 +132,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
const ChartBar = React.memo(function ChartBar(props: ChartBarProps): ReactNS.ReactElement {
|
||||
const { req, marker } = props
|
||||
const markerAt = marker !== undefined ? eventAt(marker) : null
|
||||
// Delta mode: diverging stacks — positive category deltas pile UP from the zero line, negative ones
|
||||
// hang DOWN from it, both in category colors (direction carries the sign, color the category).
|
||||
const diverge = props.upPx !== undefined && props.downPx !== undefined && props.deltaScale !== undefined
|
||||
return (
|
||||
<div
|
||||
className={'lc-bar'
|
||||
@@ -139,14 +152,33 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
title={'✂ ' + (markerAt !== null ? markerAt + ' — ' : '') + eventLabel(marker)}
|
||||
>{'✂'}</span>
|
||||
) : null}
|
||||
<div className="lc-bar-stack">
|
||||
{CATS.map((c) => {
|
||||
const v = (req[c.key] || 0) * anchorOf(req)
|
||||
if (!v) return null
|
||||
// px (not %) heights: the stack is content-driven, so percentage heights would collapse against an indefinite base.
|
||||
return <div key={c.key} style={{ height: `${Math.max(1, Math.round(v / props.maxTotal * CHART_H))}px`, background: c.color }} />
|
||||
})}
|
||||
</div>
|
||||
{diverge ? (
|
||||
<>
|
||||
<div className="lc-bar-up" style={{ bottom: `${props.downPx}px` }}>
|
||||
{CATS.map((c) => {
|
||||
const d = req[c.key] || 0
|
||||
if (d <= 0) return null
|
||||
return <div key={c.key} style={{ height: `${Math.max(1, Math.round(d * (props.deltaScale as number)))}px`, background: c.color }} />
|
||||
})}
|
||||
</div>
|
||||
<div className="lc-bar-down" style={{ top: `${props.upPx}px` }}>
|
||||
{CATS.map((c) => {
|
||||
const d = req[c.key] || 0
|
||||
if (d >= 0) return null
|
||||
return <div key={c.key} style={{ height: `${Math.max(1, Math.round(-d * (props.deltaScale as number)))}px`, background: c.color }} />
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="lc-bar-stack">
|
||||
{CATS.map((c) => {
|
||||
const v = (req[c.key] || 0) * anchorOf(req)
|
||||
if (!v) return null
|
||||
// px (not %) heights: the stack is content-driven, so percentage heights would collapse against an indefinite base.
|
||||
return <div key={c.key} style={{ height: `${Math.max(1, Math.round(v / props.maxTotal * CHART_H))}px`, background: c.color }} />
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -159,10 +191,32 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
)
|
||||
const markers = props.markers
|
||||
let maxTotal = 1
|
||||
for (const req of requests) {
|
||||
const bt = barTotalOf(req)
|
||||
if (bt > maxTotal) maxTotal = bt
|
||||
let maxUp = 0
|
||||
let maxDown = 0
|
||||
if (delta) {
|
||||
for (const req of requests) {
|
||||
let up = 0
|
||||
let down = 0
|
||||
for (const c of CATS) {
|
||||
const d = req[c.key] || 0
|
||||
if (d > 0) up += d
|
||||
else down -= d
|
||||
}
|
||||
if (up > maxUp) maxUp = up
|
||||
if (down > maxDown) maxDown = down
|
||||
}
|
||||
} else {
|
||||
for (const req of requests) {
|
||||
const bt = barTotalOf(req)
|
||||
if (bt > maxTotal) maxTotal = bt
|
||||
}
|
||||
}
|
||||
// The zero line splits the bar area PROPORTIONALLY to the larger side, so the px-per-token scale
|
||||
// is identical above and below it — a compaction's downward bar reads honestly against a growth bar.
|
||||
const span = Math.max(1, maxUp + maxDown)
|
||||
const deltaScale = CHART_H / span
|
||||
const upPx = Math.round(maxUp * deltaScale)
|
||||
const downPx = CHART_H - upPx
|
||||
|
||||
// Consecutive same-turn requests collapse into one labeled range; `span` counts the STEP columns the group covers (step records count
|
||||
// one each), so strip blocks align with the bars in both granularities.
|
||||
@@ -285,9 +339,20 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
return (
|
||||
<div className="lc-chartrow">
|
||||
<div className="lc-axis">
|
||||
<span className="lc-axis-top">{fmt(maxTotal)}</span>
|
||||
<span className="lc-axis-mid">{fmt(Math.round(maxTotal / 2))}</span>
|
||||
<span className="lc-axis-bot">{'0'}</span>
|
||||
{delta ? (
|
||||
<>
|
||||
<span className="lc-axis-top">{(maxUp > 0 ? '+' : '') + fmt(maxUp)}</span>
|
||||
{/* The 0 label rides the zero line (chart top padding 18px, half the 11px line-height up). */}
|
||||
<span className="lc-axis-mid" style={{ top: `${13 + upPx}px` }}>{'0'}</span>
|
||||
<span className="lc-axis-bot">{(maxDown > 0 ? '-' : '') + fmt(maxDown)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="lc-axis-top">{fmt(maxTotal)}</span>
|
||||
<span className="lc-axis-mid">{fmt(Math.round(maxTotal / 2))}</span>
|
||||
<span className="lc-axis-bot">{'0'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={'lc-chart-scroll' + (props.activeTurn !== null ? ' lc-chart-dim' : '')}
|
||||
@@ -303,7 +368,10 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
onMouseLeave={() => { props.onHover(null) }}
|
||||
>
|
||||
<div className="lc-grid lc-grid-top" />
|
||||
<div className="lc-grid lc-grid-mid" />
|
||||
{delta
|
||||
// A SOLID zero baseline replaces the dashed mid grid in delta mode — it is the reading reference.
|
||||
? <div className="lc-grid lc-grid-zero" style={{ top: `${18 + upPx}px` }} />
|
||||
: <div className="lc-grid lc-grid-mid" />}
|
||||
{requests.map((req, i) => (
|
||||
<ChartBar
|
||||
key={req.seq}
|
||||
@@ -313,6 +381,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactN
|
||||
hovered={props.hoveredSeq === req.seq}
|
||||
inTurn={props.activeTurn !== null && (req.turn ?? 0) === props.activeTurn}
|
||||
maxTotal={maxTotal}
|
||||
upPx={delta ? upPx : undefined}
|
||||
downPx={delta ? downPx : undefined}
|
||||
deltaScale={delta ? deltaScale : undefined}
|
||||
onSelect={props.onSelect}
|
||||
onHover={props.onHover}
|
||||
/>
|
||||
|
||||
+19
-1
@@ -98,6 +98,8 @@
|
||||
.lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l1); pointer-events: none; }
|
||||
.lc-grid-top { top: 18px; }
|
||||
.lc-grid-mid { top: 74px; }
|
||||
/* Delta mode: a SOLID zero baseline (inline-positioned) replaces the dashed mid grid as the reading reference. */
|
||||
.lc-grid-zero { border-top-style: solid; border-top-color: rgba(128,128,128,0.5); }
|
||||
.lc-bar { position: relative; width: 14px; flex: none; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; transition: opacity 120ms ease, background-color 120ms ease; }
|
||||
.lc-chart-dim .lc-bar { opacity: 0.35; }
|
||||
.lc-chart-dim .lc-bar-in-turn { opacity: 1; }
|
||||
@@ -117,6 +119,11 @@
|
||||
.lc-bar-in-turn { background: rgba(128,128,128,0.14); }
|
||||
.lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; }
|
||||
.lc-bar-stack > div { width: 100%; }
|
||||
/* Delta mode: diverging stacks anchored at the zero line — growth piles up from it, shrinkage hangs down. */
|
||||
.lc-bar-up, .lc-bar-down { position: absolute; left: 0; width: 100%; display: flex; }
|
||||
.lc-bar-up { flex-direction: column-reverse; }
|
||||
.lc-bar-down { flex-direction: column; }
|
||||
.lc-bar-up > div, .lc-bar-down > div { width: 100%; }
|
||||
.lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--dsw-alias-state-warn-primary); }
|
||||
/*
|
||||
Turn strip: NEUTRAL zebra fills (inline, see TURN_FILLS) — never the category palette, or the strip reads as a detached bottom segment of the bars.
|
||||
@@ -136,12 +143,23 @@
|
||||
.lc-detail-marker { color: var(--dsw-alias-state-warn-primary); font-size: 11px; background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border-radius: 6px; padding: 1px 7px; }
|
||||
.lc-detail-tag { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
||||
.lc-detail-metric { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; padding: 2px 8px; border-radius: 6px; color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 14%, transparent); }
|
||||
/* Delta sign semantics mirror the harness diff blocks: growth on the success token, shrinkage on the error token. */
|
||||
.lc-detail-metric-up { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); }
|
||||
.lc-detail-metric-down { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); }
|
||||
.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
|
||||
.lc-detail-row { display: flex; align-items: center; gap: 8px; }
|
||||
.lc-detail-label { min-width: 70px; white-space: nowrap; color: var(--dsw-alias-label-secondary); }
|
||||
.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; }
|
||||
.lc-bar-track { flex: 1; height: 5px; border-radius: 3px; background: rgba(128,128,128,0.18); overflow: hidden; display: block; position: relative; }
|
||||
.lc-bar-fill { display: block; height: 100%; border-radius: 3px; }
|
||||
/* Delta rows: a hairline zero at the middle, growth fills right of it, shrinkage left. */
|
||||
.lc-bar-zero { position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid rgba(128,128,128,0.45); }
|
||||
.lc-bar-fill-up, .lc-bar-fill-down { position: absolute; top: 0; height: 100%; display: block; }
|
||||
.lc-bar-fill-up { left: 50%; border-radius: 0 3px 3px 0; }
|
||||
.lc-bar-fill-down { right: 50%; border-radius: 3px 0 0 3px; }
|
||||
.lc-detail-num { width: 44px; text-align: right; }
|
||||
.lc-detail-num-up, .lc-detail-num-down { width: 52px; font-variant-numeric: tabular-nums; }
|
||||
.lc-detail-num-up { color: var(--dsw-alias-state-success-primary); }
|
||||
.lc-detail-num-down { color: var(--dsw-alias-state-error-primary); }
|
||||
.lc-detail-pct { width: 34px; text-align: right; color: var(--dsw-alias-label-secondary); }
|
||||
/*
|
||||
The row owns the 14px vertical rhythm (lc-cols gap); cards drop their own bottom margin (incl. the last in a column) so every section boundary measures exactly one 14px whichever way the flex wraps.
|
||||
|
||||
@@ -236,6 +236,58 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
|
||||
assert.equal(byClass(tr, 'lc-bar').length, 4, 'back to one bar per step')
|
||||
assert.equal(onBtns(granRow())[0].args[2], 'Step', 'step button active again')
|
||||
|
||||
// ── Delta mode: diverging bars around a zero line + signed detail panel ──
|
||||
// Fixture deltas: seq2 −5 user/−5 assistant, seq3 +15/+2, seq4 −20/−4 → maxUp 17, maxDown 24,
|
||||
// scale 112/41 → upPx 46, downPx 66.
|
||||
const modeRow = () => byClass(tr, 'lc-gran')[1].args.slice(2)
|
||||
let modeBtns = modeRow()
|
||||
assert.equal(modeBtns[0].args[2], 'Total', 'first mode button is total')
|
||||
assert.equal(modeBtns[1].args[2], 'Delta', 'second mode button is delta')
|
||||
modeBtns[1].args[1].onClick()
|
||||
tr = renderView()
|
||||
|
||||
assert.equal(textOf(byClass(tr, 'lc-axis-top')[0]), '+17', 'delta axis tops at the largest growth')
|
||||
assert.equal(textOf(byClass(tr, 'lc-axis-mid')[0]), '0', 'delta axis mid is the zero reading')
|
||||
assert.equal(byClass(tr, 'lc-axis-mid')[0].args[1].style.top, '59px', 'zero label rides the proportional zero line (13 + upPx)')
|
||||
assert.equal(textOf(byClass(tr, 'lc-axis-bot')[0]), '-24', 'delta axis bottoms at the largest shrinkage')
|
||||
const zeroLine = byClass(tr, 'lc-grid-zero')
|
||||
assert.equal(zeroLine.length, 1, 'a solid zero baseline appears in delta mode')
|
||||
assert.equal(zeroLine[0].args[1].style.top, '64px', 'zero line sits at chart padding + upPx')
|
||||
assert.equal(byClass(tr, 'lc-grid-mid').length, 0, 'the dashed mid grid is replaced')
|
||||
|
||||
const segHeights = (stack) => stack.args.slice(2).flat().filter(c => c !== null && c !== undefined).map(c => c.args[1].style.height)
|
||||
const upStacks = byClass(tr, 'lc-bar-up')
|
||||
const downStacks = byClass(tr, 'lc-bar-down')
|
||||
assert.equal(upStacks.length, 4, 'every bar carries an up stack')
|
||||
assert.equal(downStacks.length, 4, 'every bar carries a down stack')
|
||||
assert.equal(segHeights(upStacks[0]).length + segHeights(downStacks[0]).length, 0, 'the first bar starts from zero')
|
||||
assert.deepEqual(segHeights(upStacks[2]), ['41px', '5px'], 'growth piles up from the zero line (user +15, assistant +2)')
|
||||
assert.deepEqual(segHeights(downStacks[3]), ['55px', '11px'], 'shrinkage hangs down from the zero line (user −20, assistant −4)')
|
||||
|
||||
ctxSlots[1][1](3)
|
||||
tr = renderView()
|
||||
assert.match(textOf(byClass(tr, 'lc-chart-tip')[0]), /Δ \+17/, 'delta tooltip shows the signed net')
|
||||
assert.match(detailStep(tr), /Δ \+17/, 'detail header carries the net chip')
|
||||
assert.ok(!detailStep(tr).includes('Actual Prompt'), 'usage chips drop out in delta mode')
|
||||
assert.ok(byClass(tr, 'lc-detail-tag').map(n => textOf(n)).includes('Delta'), 'a delta tag marks the panel mode')
|
||||
assert.deepEqual(byClass(tr, 'lc-detail-num-up').map(n => textOf(n)), ['+15', '+2'], 'growing categories read as signed figures')
|
||||
assert.equal(byClass(tr, 'lc-detail-num-down').length, 0, 'no shrinkage on this request')
|
||||
assert.equal(byClass(tr, 'lc-bar-zero').length, 6, 'every delta row carries a zero hairline')
|
||||
assert.equal(byClass(tr, 'lc-bar-fill-up').length, 2, 'growing rows fill right of zero')
|
||||
|
||||
ctxSlots[1][1](null)
|
||||
tr = renderView()
|
||||
assert.match(detailStep(tr), /Δ -24/, 'the newest request detail nets to −24')
|
||||
assert.deepEqual(byClass(tr, 'lc-detail-num-down').map(n => textOf(n)), ['-20', '-4'], 'shrinking categories read as signed figures')
|
||||
assert.equal(byClass(tr, 'lc-bar-fill-down').length, 2, 'shrinking rows fill left of zero')
|
||||
|
||||
modeBtns = modeRow()
|
||||
modeBtns[0].args[1].onClick()
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-grid-zero').length, 0, 'total mode drops the zero baseline')
|
||||
assert.equal(byClass(tr, 'lc-grid-mid').length, 1, 'total mode restores the dashed mid grid')
|
||||
assert.match(detailStep(tr), /Actual Prompt 83\.0k/, 'total mode restores the usage chips')
|
||||
|
||||
let scroller = byClass(tr, 'lc-chart-scroll')[0]
|
||||
const fakeScroller = { scrollLeft: 200, clientWidth: 120, scrollWidth: 800, querySelectorAll: () => [] }
|
||||
scroller.args[1].onScroll({ currentTarget: fakeScroller })
|
||||
|
||||
@@ -58,7 +58,7 @@ export const FAKE_PRIMITIVES = {
|
||||
}
|
||||
|
||||
export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'detail.actual': 'Actual Prompt {n}', 'detail.output': 'Output {n}', 'detail.cache': 'Cache {n}%', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'form.notice': 'Notice', 'ev.mode.plan.on': 'Plan mode on', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
|
||||
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'block.line': '1 line', 'block.lines': '{n} lines', 'call.ok': 'OK', 'call.fail': 'Failed', 'call.exit': 'exit {n}', 'node.failed': 'Tool execution failed', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens', 'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel', 'settings.gran': 'Default trend granularity', 'settings.mode': 'Default trend display', 'settings.expand': 'Expand', 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.delta': 'Delta' }
|
||||
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'block.line': '1 line', 'block.lines': '{n} lines', 'call.ok': 'OK', 'call.fail': 'Failed', 'call.exit': 'exit {n}', 'node.failed': 'Tool execution failed', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens', 'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel', 'settings.gran': 'Default trend granularity', 'settings.mode': 'Default trend display', 'settings.expand': 'Expand', 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.delta': 'Delta', 'tip.delta': 'Δ {n}' }
|
||||
|
||||
/** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */
|
||||
export const snapshot = {
|
||||
|
||||
Reference in new issue
Block a user