diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index b88d83f..73720a8 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -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) }} /> - + = 0 ? (activeIdx > 0 ? displayRequests[activeIdx - 1] : null) : undefined} + marker={activeReq !== null ? markerOf(activeReq) : undefined} + /> )} diff --git a/src/client/components/requestDetail.tsx b/src/client/components/requestDetail.tsx index 25d8cc9..d920a7a 100644 --- a/src/client/components/requestDetail.tsx +++ b/src/client/components/requestDetail.tsx @@ -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 (
@@ -37,32 +61,59 @@ export function makeRequestDetail( ? {'✂ ' + markerAt} : null} {isTurn ? {t('detail.lastStep')} : null} + {delta ? {t('gran.delta')} : null} {fmtTime(req.time)} {/* 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 ? ( + 0 ? ' lc-detail-metric-up' : net < 0 ? ' lc-detail-metric-down' : '')}> + {t('tip.delta', { n: (net > 0 ? '+' : '') + fmt(net) })} + + ) : null} + {!delta && req.prompt !== undefined ? {t('detail.actual', { n: fmt(req.prompt) })} : null} - {req.output !== undefined + {!delta && req.output !== undefined ? {t('detail.output', { n: fmt(req.output) })} : null} - {req.prompt !== undefined && req.cacheRead !== undefined + {!delta && req.prompt !== undefined && req.cacheRead !== undefined ? {t('detail.cache', { n: cacheHitPercent(req.cacheRead, req.prompt) ?? '—' })} : null}
- +
- {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 (
{catLabel(c.key)} - 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. */} + + {v !== 0 ? ( + 0 ? 'lc-bar-fill-up' : 'lc-bar-fill-down')} + style={{ width: `${mag / maxAbs * 50}%`, background: c.color }} + /> + ) : null} + + ) : ( + 0 ? v / req.total * 100 : 0}%`, background: c.color }} /> + )} - {'≈' + fmt(v)} - {req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''} + {delta ? ( + 0 ? ' lc-detail-num-up' : v < 0 ? ' lc-detail-num-down' : '')}> + {(v > 0 ? '+' : '') + fmt(v)} + + ) : ( + {'≈' + fmt(v)} + )} + {!delta && req.total > 0 ? `${Math.round(v / req.total * 100)}%` : ''}
) })} diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index 2715528..fcdeeda 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -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 (
ReactN title={'✂ ' + (markerAt !== null ? markerAt + ' — ' : '') + eventLabel(marker)} >{'✂'} ) : null} -
- {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
- })} -
+ {diverge ? ( + <> +
+ {CATS.map((c) => { + const d = req[c.key] || 0 + if (d <= 0) return null + return
+ })} +
+
+ {CATS.map((c) => { + const d = req[c.key] || 0 + if (d >= 0) return null + return
+ })} +
+ + ) : ( +
+ {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
+ })} +
+ )}
) }) @@ -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 (
- {fmt(maxTotal)} - {fmt(Math.round(maxTotal / 2))} - {'0'} + {delta ? ( + <> + {(maxUp > 0 ? '+' : '') + fmt(maxUp)} + {/* The 0 label rides the zero line (chart top padding 18px, half the 11px line-height up). */} + {'0'} + {(maxDown > 0 ? '-' : '') + fmt(maxDown)} + + ) : ( + <> + {fmt(maxTotal)} + {fmt(Math.round(maxTotal / 2))} + {'0'} + + )}
ReactN onMouseLeave={() => { props.onHover(null) }} >
-
+ {delta + // A SOLID zero baseline replaces the dashed mid grid in delta mode — it is the reading reference. + ?
+ :
} {requests.map((req, i) => ( 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} /> diff --git a/src/client/styles.css b/src/client/styles.css index 08cfa65..c091598 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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. diff --git a/tests/chart.spec.ts b/tests/chart.spec.ts index 29b6ee7..0d35362 100644 --- a/tests/chart.spec.ts +++ b/tests/chart.spec.ts @@ -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 }) diff --git a/tests/helpers/viewBed.ts b/tests/helpers/viewBed.ts index ad6a4fc..30a1579 100644 --- a/tests/helpers/viewBed.ts +++ b/tests/helpers/viewBed.ts @@ -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 = {