feat(client): move the context-event tallies onto the events card's kind filters
The Context Stats card's inject/compaction/prune cells duplicated what the events card already tracks. Drop the three cells (the grid falls to five: turns, steps, tool calls, cache hit, cost) and show each tally inside the matching kind-filter button instead, sourced from the same precomputed counts the stats card used.
This commit is contained in:
1 parent
b68a6d50ad
commit
8d2ddeff55
8 files changed
+77
-64
No files matched your search
@@ -47,7 +47,7 @@ Open any session and click the **Context / 上下文** tab:
|
||||
|
||||
| Card | The question it answers |
|
||||
| --- | --- |
|
||||
| **Context Stats** | Turns, steps, live tool calls & images, context-event tallies — plus a list-price cost estimate (hover the `?` for per-1M rates). |
|
||||
| **Context Stats** | Turns, steps, live tool calls, the session's cache-hit rate — plus a list-price cost estimate (hover the `?` for per-1M rates). |
|
||||
| **Token Stats** | Where the billed tokens went: cache read/write, uncached input, output — around the cache-hit ring. |
|
||||
| **Timing Stats** | How active time split across model calls, tool runs, and overhead. |
|
||||
| **Current Context** | What's in the window *right now*. |
|
||||
@@ -102,7 +102,7 @@ Pick **Live (next request)** or any retained step, and browse what that request
|
||||
|
||||

|
||||
|
||||
Every injection, compaction, prune, model switch, and plan-mode toggle — labeled with its producer (instruction file, plugin id, skill name), its net token delta (compactions show what they reclaimed), turn/step, and time. The **Inject / Compact / Prune / Switch / Mode** chips filter the log by kind.
|
||||
Every injection, compaction, prune, model switch, and plan-mode toggle — labeled with its producer (instruction file, plugin id, skill name), its net token delta (compactions show what they reclaimed), turn/step, and time. The **Inject / Compact / Prune / Switch / Mode** chips filter the log by kind, each carrying its whole-session event tally.
|
||||
|
||||
### 📁 File Activity — what the agent did to your files
|
||||
|
||||
|
||||
@@ -196,6 +196,13 @@ export function makeContextView(
|
||||
// below the last hook.
|
||||
const requests = data ? data.requests : []
|
||||
const events = data ? data.events : []
|
||||
// The stats board's count figures — the split head's precomputed tallies, or
|
||||
// the collections' own derivation on the inline generation. Shared by the
|
||||
// stats card's shape cells and the events card's kind-filter counts.
|
||||
const counts = data?.counts ?? countsOfRecords(requests, events)
|
||||
// The per-kind tallies that ride the filter buttons: only the three priced
|
||||
// kinds the host counts (model/mode switches carry no tally, hence undefined).
|
||||
const kindCounts: Record<string, number | undefined> = { inject: counts.injects, compaction: counts.compactions, prune: counts.prunes }
|
||||
const shownEvents = pickedKinds.length === EVENT_KINDS.length ? events : events.filter(e => pickedKinds.includes(e.kind))
|
||||
// Per-step bars, or one per turn (each turn's LAST step's record); memoized so hover-driven re-renders keep bar props identity-stable —
|
||||
// the chart's memoized bars then skip reconciliation (turn-mode aggregation allocates).
|
||||
@@ -506,7 +513,7 @@ export function makeContextView(
|
||||
stacks the pair in the narrow pane. */}
|
||||
{inSidebar ? null : (
|
||||
<div className="lc-cols lc-head">
|
||||
<StatsContext counts={data.counts ?? countsOfRecords(requests, events)} toolCalls={data.toolCalls} usage={usage}
|
||||
<StatsContext counts={counts} toolCalls={data.toolCalls} usage={usage}
|
||||
cost={data.cost} locale={activeLocale} />
|
||||
<PluginInfo />
|
||||
</div>
|
||||
@@ -533,13 +540,20 @@ export function makeContextView(
|
||||
<div className="lc-card-title">
|
||||
<span className="lc-card-title-text">{t('events.title')}</span>
|
||||
<div className="lc-kinds">
|
||||
{EVENT_KINDS.map(k => (
|
||||
<button
|
||||
key={k}
|
||||
className={'lc-gran-btn' + (pickedKinds.includes(k) ? ' lc-gran-on lc-kind-' + k : '')}
|
||||
onClick={() => { toggleKind(k) }}
|
||||
>{t('kind.' + k)}</button>
|
||||
))}
|
||||
{EVENT_KINDS.map((k) => {
|
||||
const n = kindCounts[k]
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
data-kind={k}
|
||||
className={'lc-gran-btn' + (pickedKinds.includes(k) ? ' lc-gran-on lc-kind-' + k : '')}
|
||||
onClick={() => { toggleKind(k) }}
|
||||
>
|
||||
{t('kind.' + k)}
|
||||
{n !== undefined ? <span className="lc-kind-n">{kit.fmt(n)}</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<EventList events={shownEvents} state={source.detailState} onRetry={source.retryDetail} />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* The Context card: what the session's context IS and how it evolved — an
|
||||
* eight-cell 2×4 grid pairing the session's shape (turns / steps / live tool
|
||||
* calls / the whole-session cache-hit rate) with the context-event tally
|
||||
* (injections / compactions / prunes) and the whole-session cost estimate.
|
||||
* The Context card: what the session's context IS and how it evolved — a
|
||||
* five-cell grid of the session's shape (turns / steps / live tool calls),
|
||||
* the whole-session cache-hit rate, and the whole-session cost estimate.
|
||||
* Count figures only: nothing here is part of a spendable whole, so no pie —
|
||||
* proportions live in the composition card. The cache-hit cell reads the
|
||||
* official `tokenUsage` projection — the same source and formula as the
|
||||
* harness chat stats line under the composer, shown with one decimal — and
|
||||
* dashes until a provider reports usage. The cost cell prices the
|
||||
* host-folded cumulative billed totals (complete session log, never trimmed)
|
||||
* at the hardcoded DeepSeek V4 list prices (cost.ts) in the locale's
|
||||
* proportions live in the composition card, and the context-event tallies
|
||||
* live on the events card's kind filters (contextView.tsx). The cache-hit
|
||||
* cell reads the official `tokenUsage` projection — the same source and
|
||||
* formula as the harness chat stats line under the composer, shown with one
|
||||
* decimal — and dashes until a provider reports usage. The cost cell prices
|
||||
* the host-folded cumulative billed totals (complete session log, never
|
||||
* trimmed) at the hardcoded DeepSeek V4 list prices (cost.ts) in the locale's
|
||||
* currency; its hover bubble (a '?' marker + styled DOM tip) explains the
|
||||
* whole-session estimate and lists the per-1M-token table straight from
|
||||
* cost.ts, so printed rates can never drift from the math.
|
||||
@@ -49,7 +49,7 @@ export function countsOfRecords(requests: readonly RequestRecord[], events: read
|
||||
}
|
||||
|
||||
export function makeStatsContext(kit: ViewKit): (props: {
|
||||
/** The session-shape and event tallies (host-precomputed on the split generation). */
|
||||
/** The session-shape tally (host-precomputed on the split generation). */
|
||||
counts: TimelineCounts
|
||||
/** Tool calls with a result live in the current context (absent on older hosts). */
|
||||
toolCalls?: number
|
||||
@@ -112,9 +112,6 @@ export function makeStatsContext(kit: ViewKit): (props: {
|
||||
{cell(t('stats.toolCalls'), props.toolCalls ?? 0)}
|
||||
{cell(t('stats.cacheHit'), hit === null ? '—' : `${hit}%`)}
|
||||
{cell(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip)}
|
||||
{cell(t('stats.injects'), props.counts.injects)}
|
||||
{cell(t('stats.compactions'), props.counts.compactions)}
|
||||
{cell(t('stats.prunes'), props.counts.prunes)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,9 +18,6 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'overview.compactReserve': '自动压缩预留:占用达 {pct}% 窗口时触发压缩,此区域一般不实际占用',
|
||||
'stats.title': '上下文统计',
|
||||
'stats.turns': '轮次', 'stats.steps': '步数',
|
||||
'stats.injects': '注入',
|
||||
'stats.compactions': '压缩',
|
||||
'stats.prunes': '剪枝',
|
||||
'stats.toolCalls': '工具调用',
|
||||
'stats.cacheHit': '缓存命中',
|
||||
'stats.cost': '预估费用',
|
||||
@@ -247,9 +244,6 @@ export const DICT_EN: Record<string, string> = {
|
||||
'overview.compactReserve': 'Auto-compaction reserve: auto-compaction triggers at {pct}% of the window — this area is normally kept as headroom, not actually used',
|
||||
'stats.title': 'Context Stats',
|
||||
'stats.turns': 'Turns', 'stats.steps': 'Steps',
|
||||
'stats.injects': 'Injections',
|
||||
'stats.compactions': 'Compactions',
|
||||
'stats.prunes': 'Prunes',
|
||||
'stats.toolCalls': 'Tool Calls',
|
||||
'stats.cacheHit': 'Cache Hit',
|
||||
'stats.cost': 'Cost',
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
.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; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.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); }
|
||||
/* The events card's kind filters carry their per-kind event tally: a tabular
|
||||
count set off from the label, dimmed while the chip is off and inherited
|
||||
(slightly translucent) on the lit chip. */
|
||||
.lc-kind-n { margin-left: 4px; font-variant-numeric: tabular-nums; opacity: 0.65; }
|
||||
.lc-gran-on .lc-kind-n { opacity: 0.85; }
|
||||
/*
|
||||
Shared tooltip-bubble chrome: pair `.lc-tip` with the owner's own tip class,
|
||||
then position locally (top/bottom anchor, width cap, whitespace, reveal rule).
|
||||
@@ -100,7 +105,7 @@
|
||||
`min-width: 0` lets the split ignore content. Each row is its own
|
||||
`lc-head-row` query container, so the split folds instead of crushing the
|
||||
cards (issue #48): below 720px of row width (a half under ~350px — where the
|
||||
donut cards' ring+legend single-line and the context card's 4-column cell
|
||||
donut cards' ring+legend single-line and the context card's 3-column cell
|
||||
grid both start to cramp) the pair stacks single-column. Cards under that
|
||||
end of the band sit under their own lc-card reflows (stats.css: the ring
|
||||
stacks over its legend, the cell grid folds), so the two-card line is the
|
||||
|
||||
+13
-13
@@ -1,15 +1,15 @@
|
||||
/*
|
||||
* The head band's stat cards — the context/plugin row and the token/timing
|
||||
* donut row: the context card's eight-cell grid with the cost cell's styled
|
||||
* tip, the composition overview's big number, and the cards' shared anatomy
|
||||
* donut row: the context card's count grid with the cost cell's styled tip,
|
||||
* the composition overview's big number, and the cards' shared anatomy
|
||||
* — section heading, donut, slice list, plugin-info rows.
|
||||
*/
|
||||
/* The context card's eight count cells: shape stats over event tallies,
|
||||
4×2 by default — wide-enough cards lay all eight out in ONE row (the
|
||||
min-width tier below), narrow cards fold to two columns. */
|
||||
.lc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
||||
/* The context card's five count cells: the session's shape, the cache-hit
|
||||
rate, and the cost — 3×2 by default (the cells stay wide enough for the
|
||||
longest English label), folding to a single row on wide cards. */
|
||||
.lc-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.lc-stat { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 6px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
/* The 8-cell stats grid quarters an equal head-row card, so its cells are narrow: tighter padding plus a corner-pinned '?' (out of the label's line) keep the longest label (预估费用) on one line. */
|
||||
/* The 5-cell stats grid shares a half-width head-row card with a sibling, so its cells are moderate: tighter padding plus a corner-pinned '?' (out of the label's line) keep the longest labels on one line. */
|
||||
.lc-stats .lc-stat { padding: 6px 3px; }
|
||||
.lc-stats .lc-stat-q { position: absolute; top: 3px; right: 4px; margin: 0; }
|
||||
.lc-stat-label { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -104,20 +104,20 @@
|
||||
.lc-pi-value { min-width: 0; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
||||
.lc-pi-row-btn .lc-pi-value { font-weight: 400; }
|
||||
/*
|
||||
Wide head cards: on roomy panes the context card's eight cells fit ONE
|
||||
line (~82px each at the 700px mark). Degrades silently on engines without
|
||||
Wide head cards: on roomy panes the context card's five cells fit ONE line
|
||||
(~130px each at the 700px mark). Degrades silently on engines without
|
||||
container queries.
|
||||
*/
|
||||
@container lc-card (min-width: 700px) {
|
||||
.lc-stats { grid-template-columns: repeat(8, minmax(0, 1fr)); }
|
||||
.lc-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
/*
|
||||
Narrow head cards: the side-by-side ring + legend reads single-line only
|
||||
above ~320px of card content width. The two-tier rows already absorb the
|
||||
squeeze — the name gives ground before the fixed share column — so the
|
||||
compact block only tightens the ring gap. The context card's 8-cell grid
|
||||
folds to 2 columns instead: four ~65px cells would ellipsize the longer
|
||||
English labels (Compactions, Injections). Below 240px even the ring stops
|
||||
compact block only tightens the ring gap. The context card's 5-cell grid
|
||||
folds to 2 columns instead: three ~65px cells would ellipsize the longer
|
||||
English labels (Tool Calls). Below 240px even the ring stops
|
||||
fitting, so the card stacks: the ring centers over the full-width legend
|
||||
rows. Cards resolve these queries against themselves (the shared `lc-card`
|
||||
container in base.css) at every split of the head band — two-across or
|
||||
|
||||
@@ -490,15 +490,21 @@ describe('ContextView — interactions', () => {
|
||||
test('event-kind filter narrows, unions, drops, and resets', async () => {
|
||||
const m = await mountRich('sv-kinds')
|
||||
const countEvents = () => queryAll(m.container, '.lc-event').length
|
||||
const kindBtn = (k: string) => query(m.container, `.lc-kinds [data-kind="${k}"]`) as HTMLElement
|
||||
assert.equal(countEvents(), 2)
|
||||
// The buttons carry their per-kind tallies (richTimeline: 1 inject, 1 compaction, 0 prunes).
|
||||
assert.deepEqual(
|
||||
queryAll(m.container, '.lc-kinds .lc-kind-n').map(el => text(el)),
|
||||
['1', '1', '0'],
|
||||
)
|
||||
|
||||
await click(buttonByText(m.container, DICT_EN['kind.inject']))
|
||||
await click(kindBtn('inject'))
|
||||
assert.equal(countEvents(), 1)
|
||||
await click(buttonByText(m.container, DICT_EN['kind.compaction']))
|
||||
await click(kindBtn('compaction'))
|
||||
assert.equal(countEvents(), 2)
|
||||
await click(buttonByText(m.container, DICT_EN['kind.compaction']))
|
||||
await click(kindBtn('compaction'))
|
||||
assert.equal(countEvents(), 1)
|
||||
await click(buttonByText(m.container, DICT_EN['kind.inject']))
|
||||
await click(kindBtn('inject'))
|
||||
assert.equal(countEvents(), 2)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// StatsContext (src/client/components/statsContext.tsx) rendered with real
|
||||
// React: the eight-cell 2×4 grid — session shape, the chat-line cache-hit
|
||||
// cell, the priced cost cell with its rate tooltip, and the context-event
|
||||
// tally — in both locales. The count figures arrive precomputed (the split
|
||||
// generation's wire head carries them); `countsOfRecords` is the inline
|
||||
// generation's derivation, pinned here to the same totals.
|
||||
// React: the five-cell grid — session shape, the chat-line cache-hit cell,
|
||||
// and the priced cost cell with its rate tooltip — in both locales. The
|
||||
// context-event tallies live on the events card's kind filters
|
||||
// (contextView.spec.ts); `countsOfRecords` still derives every count the
|
||||
// split generation's wire head carries, pinned here.
|
||||
|
||||
import { createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
@@ -57,7 +57,7 @@ describe('countsOfRecords (the inline generation derivation)', () => {
|
||||
})
|
||||
|
||||
describe('StatsContext', () => {
|
||||
test('folds the eight-cell grid: shape stats, the cache-hit cell, cost, and the event tally', async () => {
|
||||
test('folds the five-cell grid: shape stats, the cache-hit cell, and cost', async () => {
|
||||
const m = await mount(h(StatsContext, {
|
||||
counts: { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1 },
|
||||
toolCalls: 3,
|
||||
@@ -67,12 +67,9 @@ describe('StatsContext', () => {
|
||||
}))
|
||||
assert.ok(text(m.container).includes('Context Stats'))
|
||||
const { labels, values } = cells(m.container)
|
||||
assert.equal(labels.length, 8)
|
||||
assert.deepEqual(labels, [
|
||||
'Turns', 'Steps', 'Tool Calls', 'Cache Hit',
|
||||
'Cost?', 'Injections', 'Compactions', 'Prunes',
|
||||
])
|
||||
assert.deepEqual(values, ['3', '4', '3', '66.6%', '$0.30', '3', '2', '1'])
|
||||
assert.equal(labels.length, 5)
|
||||
assert.deepEqual(labels, ['Turns', 'Steps', 'Tool Calls', 'Cache Hit', 'Cost?'])
|
||||
assert.deepEqual(values, ['3', '4', '3', '66.6%', '$0.30'])
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
@@ -82,7 +79,7 @@ describe('StatsContext', () => {
|
||||
usage: null,
|
||||
locale: 'en',
|
||||
}))
|
||||
assert.deepEqual(cells(m.container).values, ['0', '0', '0', '—', '—', '0', '0', '0'])
|
||||
assert.deepEqual(cells(m.container).values, ['0', '0', '0', '—', '—'])
|
||||
await m.unmount()
|
||||
// A usage report with nothing billed prompt-side dashes the hit too.
|
||||
const zero: TokenUsage = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
@@ -91,7 +88,7 @@ describe('StatsContext', () => {
|
||||
usage: zero,
|
||||
locale: 'en',
|
||||
}))
|
||||
assert.deepEqual(cells(m2.container).values, ['0', '0', '0', '—', '—', '0', '0', '0'])
|
||||
assert.deepEqual(cells(m2.container).values, ['0', '0', '0', '—', '—'])
|
||||
await m2.unmount()
|
||||
})
|
||||
|
||||
@@ -122,8 +119,8 @@ describe('StatsContext', () => {
|
||||
}))
|
||||
assert.ok(text(m.container).includes('上下文统计'))
|
||||
const { labels, values } = cells(m.container)
|
||||
assert.deepEqual(labels, ['轮次', '步数', '工具调用', '缓存命中', '预估费用?', '注入', '压缩', '剪枝'])
|
||||
assert.deepEqual(values, ['1', '1', '0', '66.6%', '¥2.00', '0', '1', '0'])
|
||||
assert.deepEqual(labels, ['轮次', '步数', '工具调用', '缓存命中', '预估费用?'])
|
||||
assert.deepEqual(values, ['1', '1', '0', '66.6%', '¥2.00'])
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user