dsh-token-total 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/lib/client.js +678 -127
- package/lib/index.js +281 -4
- package/lib/types/client/ContextWindowPanel.d.ts +18 -0
- package/lib/types/client/SessionUsageView.d.ts +3 -2
- package/lib/types/client/context-window-core.d.ts +49 -0
- package/lib/types/client/session-usage-locales.d.ts +28 -0
- package/lib/types/context-window.d.ts +36 -0
- package/lib/types/store.d.ts +8 -1
- package/lib/types/wire.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/ContextWindowPanel.module.css +77 -0
- package/src/client/ContextWindowPanel.tsx +222 -0
- package/src/client/SessionUsageView.module.css +89 -64
- package/src/client/SessionUsageView.tsx +30 -26
- package/src/client/context-window-core.ts +137 -0
- package/src/client/session-usage-locales.ts +36 -0
- package/src/collector.ts +4 -1
- package/src/context-window.ts +302 -0
- package/src/session-detail.ts +3 -2
- package/src/store.ts +9 -1
- package/src/wire.ts +53 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts'
|
|
3
|
+
import { CONTEXT_CATEGORY_ORDER, contextCategoryTotals, contextReading, treemapLayout } from './context-window-core.ts'
|
|
4
|
+
import { cacheRate, formatCompact, formatPercent, formatTokens } from './format.ts'
|
|
5
|
+
import type { SessionUsageKey } from './session-usage-locales.ts'
|
|
6
|
+
import css from './ContextWindowPanel.module.css'
|
|
7
|
+
|
|
8
|
+
type T = (key: SessionUsageKey, params?: Record<string, string>) => string
|
|
9
|
+
|
|
10
|
+
const CATEGORY_LABEL: Record<SessionContextCategory, SessionUsageKey> = {
|
|
11
|
+
system: 'contextSystem', tools: 'contextTools', memory: 'contextMemory',
|
|
12
|
+
files: 'contextFiles', results: 'contextResults', conversation: 'contextConversation',
|
|
13
|
+
}
|
|
14
|
+
const CATEGORY_TONE: Record<SessionContextCategory, string> = {
|
|
15
|
+
system: css.toneSystem, tools: css.toneTools, memory: css.toneMemory,
|
|
16
|
+
files: css.toneFiles, results: css.toneResults, conversation: css.toneConversation,
|
|
17
|
+
}
|
|
18
|
+
/** Which locale string names an injected/system row, keyed by its log kind. */
|
|
19
|
+
const KIND_LABEL: Record<string, SessionUsageKey> = {
|
|
20
|
+
'system-prompt': 'contextKindSystemPrompt',
|
|
21
|
+
'runtime-context': 'contextKindRuntimeContext',
|
|
22
|
+
'skill-catalog': 'contextKindSkillCatalog',
|
|
23
|
+
'time-context': 'contextKindTimeContext',
|
|
24
|
+
'compaction-summary': 'contextKindCompaction',
|
|
25
|
+
user: 'contextKindUser',
|
|
26
|
+
assistant: 'contextKindAssistant',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What one treemap tile is called: a tool name or path as-is, an
|
|
30
|
+
* injected kind through the dictionary, and a folded tail as "N more". */
|
|
31
|
+
function itemText(item: SessionContextItem, t: T): string {
|
|
32
|
+
if (item.kind === 'rest') return t('contextKindRest', { count: String(item.count) })
|
|
33
|
+
const label = item.kind === undefined ? undefined : KIND_LABEL[item.kind]
|
|
34
|
+
const base = label !== undefined ? t(label) : item.label || item.kind || ''
|
|
35
|
+
// Repeated entries of one kind (a tool ran 63 times, five user prompts) keep
|
|
36
|
+
// their multiplicity visible: that repetition is what makes them bulky.
|
|
37
|
+
return item.count > 1 ? `${base} ${t('contextTimes', { count: String(item.count) })}` : base
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sumOf = (items: readonly SessionContextItem[]): number => items.reduce((sum, item) => sum + item.tokens, 0)
|
|
41
|
+
|
|
42
|
+
/** Wall-clock stamp of the last successful read, same shape as the page's. */
|
|
43
|
+
const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
44
|
+
|
|
45
|
+
/** Hover-card footprint estimate, so the tip can be kept inside the map
|
|
46
|
+
* without measuring itself first. */
|
|
47
|
+
const TIP_W = 220
|
|
48
|
+
const TIP_H = 70
|
|
49
|
+
|
|
50
|
+
/** The hover card for one tile: name and call count, the exact token count
|
|
51
|
+
* with its category, and the block's share of the estimate and of the
|
|
52
|
+
* model's window — the figures a tile has no room to print. */
|
|
53
|
+
function Tip({ item, box, map, t, total, contextWindow }: {
|
|
54
|
+
item: SessionContextItem
|
|
55
|
+
box: { x: number; y: number; width: number; height: number }
|
|
56
|
+
map: { width: number; height: number }
|
|
57
|
+
t: T
|
|
58
|
+
total: number
|
|
59
|
+
contextWindow: number | null
|
|
60
|
+
}): JSX.Element {
|
|
61
|
+
const share = total > 0 ? formatPercent(item.tokens / total * 100) : null
|
|
62
|
+
const windowShare = contextWindow !== null && contextWindow > 0 ? formatPercent(item.tokens / contextWindow * 100) : null
|
|
63
|
+
// Under the tile's center by default, flipping above it near the map floor,
|
|
64
|
+
// clamped to the map so overflow:hidden never clips the tip away.
|
|
65
|
+
const left = Math.min(Math.max(box.x + box.width / 2 - TIP_W / 2, 4), Math.max(4, map.width - TIP_W - 4))
|
|
66
|
+
const top = box.y + box.height + 6 + TIP_H < map.height - 4
|
|
67
|
+
? box.y + box.height + 6
|
|
68
|
+
: Math.max(4, box.y - TIP_H - 6)
|
|
69
|
+
return <div className={css.tip} role="tooltip" style={{ left, top }}>
|
|
70
|
+
<div className={css.tipName}>{itemText(item, t)}</div>
|
|
71
|
+
<div className={css.tipMeta}>{formatTokens(item.tokens)} token · {t(CATEGORY_LABEL[item.category])}</div>
|
|
72
|
+
{share !== null && <div className={css.tipShare}>
|
|
73
|
+
{t('contextTipShare', { percent: share })}{windowShare === null ? '' : ` · ${t('contextTipWindow', { percent: windowShare })}`}
|
|
74
|
+
</div>}
|
|
75
|
+
</div>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Treemap({ items, t, contextWindow }: { items: SessionContextItem[]; t: T; contextWindow: number | null }): JSX.Element {
|
|
79
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
80
|
+
const [size, setSize] = useState({ width: 0, height: 0 })
|
|
81
|
+
// Hover state is keyed by block, not by tile instance, so the tip follows its
|
|
82
|
+
// block across refreshes even while the boxes re-lay-out underneath it.
|
|
83
|
+
const [hoverKey, setHoverKey] = useState<string | null>(null)
|
|
84
|
+
// The treemap needs real pixels: measure the box and re-lay out on resize
|
|
85
|
+
// (the Usage page is a container-query surface, so widths change freely).
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
const host = hostRef.current
|
|
88
|
+
if (host === null) return
|
|
89
|
+
const measure = (): void => setSize(previous => {
|
|
90
|
+
const width = host.clientWidth
|
|
91
|
+
const height = host.clientHeight
|
|
92
|
+
return previous.width === width && previous.height === height ? previous : { width, height }
|
|
93
|
+
})
|
|
94
|
+
measure()
|
|
95
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
96
|
+
window.addEventListener('resize', measure)
|
|
97
|
+
return () => window.removeEventListener('resize', measure)
|
|
98
|
+
}
|
|
99
|
+
const observer = new ResizeObserver(measure)
|
|
100
|
+
observer.observe(host)
|
|
101
|
+
return () => observer.disconnect()
|
|
102
|
+
}, [])
|
|
103
|
+
const boxes = useMemo(
|
|
104
|
+
() => treemapLayout(items.map(item => ({ key: item.key, tokens: item.tokens })), size.width, size.height),
|
|
105
|
+
[items, size.width, size.height],
|
|
106
|
+
)
|
|
107
|
+
const byKey = useMemo(() => new Map(items.map(item => [item.key, item])), [items])
|
|
108
|
+
const hoverBox = hoverKey === null ? undefined : boxes.find(box => box.key === hoverKey)
|
|
109
|
+
const hoverItem = hoverBox === undefined ? undefined : byKey.get(hoverBox.key)
|
|
110
|
+
return <div ref={hostRef} className={css.map} onMouseLeave={() => setHoverKey(null)}>
|
|
111
|
+
{boxes.map(box => {
|
|
112
|
+
const item = byKey.get(box.key)
|
|
113
|
+
if (item === undefined || box.width <= 0 || box.height <= 0) return null
|
|
114
|
+
const text = itemText(item, t)
|
|
115
|
+
// Tiles shed their captions before they shed their color: a label needs
|
|
116
|
+
// room for two glyphs plus padding, the size line needs a second row.
|
|
117
|
+
const showLabel = box.width >= 46 && box.height >= 22
|
|
118
|
+
const showSize = box.width >= 62 && box.height >= 40
|
|
119
|
+
return <div key={box.key} className={css.slot}
|
|
120
|
+
style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
|
|
121
|
+
onMouseEnter={() => setHoverKey(box.key)}
|
|
122
|
+
aria-label={`${text} · ${formatTokens(item.tokens)} token · ${t(CATEGORY_LABEL[item.category])}`}>
|
|
123
|
+
<div className={`${css.tile} ${CATEGORY_TONE[item.category]}`}>
|
|
124
|
+
{showLabel && <span className={css.tileLabel}>{text}</span>}
|
|
125
|
+
{showSize && <span className={css.tileSize}>{formatCompact(item.tokens)} · {t(CATEGORY_LABEL[item.category])}</span>}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
})}
|
|
129
|
+
{hoverBox !== undefined && hoverItem !== undefined &&
|
|
130
|
+
<Tip item={hoverItem} box={hoverBox} map={size} t={t} total={sumOf(items)} contextWindow={contextWindow} />}
|
|
131
|
+
</div>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The context-window panel above the Usage cards, in magpie's shape: the used
|
|
135
|
+
* total and the model's window on the left, the newest answer's prompt-cache
|
|
136
|
+
* split on the right, a treemap of what occupies the window below (with a
|
|
137
|
+
* hover card per tile), and one legend row naming the six buckets with their
|
|
138
|
+
* sizes. Every figure is an estimate, so the panel never claims an exact
|
|
139
|
+
* count — and the live pill stamps when the estimate was last refreshed. */
|
|
140
|
+
export function ContextWindowPanel({ context, t, updatedAt }: {
|
|
141
|
+
context: SessionContextWindow
|
|
142
|
+
t: T
|
|
143
|
+
/** Wall-clock ms of the last successful read, when the host reports one. */
|
|
144
|
+
updatedAt?: number | null
|
|
145
|
+
}): JSX.Element {
|
|
146
|
+
const [open, setOpen] = useState(true)
|
|
147
|
+
const totals = contextCategoryTotals(context.items)
|
|
148
|
+
const reading = contextReading(context)
|
|
149
|
+
const percent = reading.percent
|
|
150
|
+
const hitRate = context.cacheKnown ? cacheRate(context.cacheHitTokens, context.cacheMissTokens) : null
|
|
151
|
+
const model = context.model !== '' && !context.model.includes('/') && context.provider !== ''
|
|
152
|
+
? `${context.provider}/${context.model}`
|
|
153
|
+
: context.model
|
|
154
|
+
const windowText = context.contextWindow === null
|
|
155
|
+
? t('contextWindowUnknown')
|
|
156
|
+
: t('contextWindowKnown', {
|
|
157
|
+
value: formatCompact(context.contextWindow),
|
|
158
|
+
percent: String(percent ?? 0),
|
|
159
|
+
})
|
|
160
|
+
return <section className={css.panel} data-dsh-context-window>
|
|
161
|
+
{/* Collapsed, the body's spacing is gone, so the header pads its own
|
|
162
|
+
bottom — otherwise the folded row reads heavy on top. */}
|
|
163
|
+
<header className={open ? css.head : `${css.head} ${css.headCollapsed}`}>
|
|
164
|
+
<button type="button" className={css.toggle} onClick={() => setOpen(!open)} aria-expanded={open} aria-controls="dsh-context-window-body">
|
|
165
|
+
<svg className={`${css.chevron} ${open ? css.chevronOpen : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
|
|
166
|
+
<h2>{t('contextTitle')}</h2>
|
|
167
|
+
</button>
|
|
168
|
+
<div className={css.headRight}>
|
|
169
|
+
{model !== '' && <span className={css.model} title={`${context.provider} / ${context.model}`}>{model}</span>}
|
|
170
|
+
<span className={css.livePill}>
|
|
171
|
+
<span className={css.liveDot} aria-hidden="true" />
|
|
172
|
+
{t('contextLive')}
|
|
173
|
+
{typeof updatedAt === 'number' && <span className={css.liveTime}> · {clock(updatedAt)}</span>}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
</header>
|
|
177
|
+
<div id="dsh-context-window-body" className={css.body} hidden={!open}>
|
|
178
|
+
<div className={css.summary}>
|
|
179
|
+
<div className={css.used}>
|
|
180
|
+
<span className={css.label}>{t('contextUsed')}</span>
|
|
181
|
+
<strong className={css.total} title={formatTokens(reading.usedTokens)}>{formatCompact(reading.usedTokens)}<small>token</small></strong>
|
|
182
|
+
<span className={css.window}>{windowText}</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div className={css.cache}>
|
|
185
|
+
<span className={css.label}>{t('contextCache')}</span>
|
|
186
|
+
<div className={css.cacheBar}>
|
|
187
|
+
{hitRate === null
|
|
188
|
+
? <span className={css.cacheEmpty} />
|
|
189
|
+
: <>
|
|
190
|
+
<span className={css.cacheHit} style={{ width: `${hitRate}%` }} />
|
|
191
|
+
<span className={css.cacheMiss} style={{ width: `${100 - hitRate}%` }} />
|
|
192
|
+
</>}
|
|
193
|
+
</div>
|
|
194
|
+
<div className={css.cacheLegend}>
|
|
195
|
+
{hitRate === null
|
|
196
|
+
? <span className={css.cachePending}>{t('contextCachePending')}</span>
|
|
197
|
+
: <>
|
|
198
|
+
<span><i className={css.cacheHitDot} aria-hidden="true" />{t('contextCacheHit')} {formatPercent(hitRate)}</span>
|
|
199
|
+
<span><i className={css.cacheMissDot} aria-hidden="true" />{t('contextCacheMiss')} {formatPercent(100 - hitRate)}</span>
|
|
200
|
+
</>}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
{reading.estimateDiffers && <p className={css.note}>
|
|
205
|
+
{t('contextEstimateNote', { estimate: formatCompact(reading.estimateTokens), billed: formatCompact(reading.usedTokens) })}
|
|
206
|
+
</p>}
|
|
207
|
+
{context.items.length === 0
|
|
208
|
+
? <p className={css.empty}>{t('contextEmpty')}</p>
|
|
209
|
+
: <Treemap items={context.items} t={t} contextWindow={context.contextWindow} />}
|
|
210
|
+
{totals.length > 0 && <ul className={css.legend}>
|
|
211
|
+
{totals.map(row => <li key={row.key}>
|
|
212
|
+
<span className={`${css.swatch} ${CATEGORY_TONE[row.key]}`} aria-hidden="true" />
|
|
213
|
+
<span className={css.legendName}>{t(CATEGORY_LABEL[row.key])}</span>
|
|
214
|
+
<span className={css.legendValue}>{formatCompact(row.tokens)}</span>
|
|
215
|
+
</li>)}
|
|
216
|
+
</ul>}
|
|
217
|
+
</div>
|
|
218
|
+
</section>
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Legend order shared with the CSS tone classes (kept exported for tests). */
|
|
222
|
+
export const CONTEXT_LEGEND_ORDER = CONTEXT_CATEGORY_ORDER
|
|
@@ -1,46 +1,57 @@
|
|
|
1
|
-
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/* DSH-native Usage surface: every colour rides the host semantic tokens
|
|
2
|
+
* (--dsw-alias-*), so the page follows the DSH light/dark theme — the light
|
|
3
|
+
* canvas is the host's pure-white bg-base, the dark canvas its near-black
|
|
4
|
+
* base, and surfaces separate by border (the host's flat-light convention)
|
|
5
|
+
* instead of by canvas tint. Tables fit the available width: no horizontal
|
|
6
|
+
* scroll containers, fixed column contracts, titles for names. Six stat cards
|
|
7
|
+
* sit in one row (1050px and below: 3-up, 700px and below: 2-up) as quiet
|
|
8
|
+
* bordered surfaces: no accent edges or watermarks, a flat tone chip leading
|
|
9
|
+
* each label, hierarchy from the type scale. Request log folds by
|
|
10
|
+
* conversation turn.
|
|
11
|
+
* Accent hues are this plugin's tone scale — the same accents the dashboard
|
|
12
|
+
* cards wear. Dark values flip under body[data-ds-dark-theme], the host's own
|
|
13
|
+
* dark mechanism (never a prefers-color-scheme media query: the host theme is
|
|
14
|
+
* app-level and can disagree with the OS). */
|
|
15
|
+
.scroll { height:100%; min-height:0; overflow-y:auto; background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); container-type:inline-size; }
|
|
16
|
+
.page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:var(--dsw-font-family); font-size:13px; }
|
|
7
17
|
.page * { box-sizing:border-box; }
|
|
8
18
|
/* Freshness header: live badge + last-read stamp on the left, manual refresh
|
|
9
19
|
* pinned to the right edge. */
|
|
10
20
|
.head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 12px; }
|
|
11
|
-
.headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color
|
|
12
|
-
.liveDot { width:6px; height:6px; border-radius:50%; background
|
|
13
|
-
.asOf { color
|
|
14
|
-
.refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color
|
|
15
|
-
.refresh:hover { color
|
|
21
|
+
.headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:var(--dsw-alias-label-tertiary); }
|
|
22
|
+
.liveDot { width:6px; height:6px; border-radius:50%; background:var(--dsw-alias-state-success-primary); box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); flex-shrink:0; }
|
|
23
|
+
.asOf { color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
24
|
+
.refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l2); border-radius:8px; padding:5px 11px; font:inherit; font-size:11px; cursor:pointer; }
|
|
25
|
+
.refresh:hover { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l3); background:var(--dsw-alias-interactive-bg-hover); }
|
|
16
26
|
.refresh:disabled { opacity:.55; cursor:default; }
|
|
17
|
-
.scope { color
|
|
18
|
-
.stats { display:grid; gap:
|
|
19
|
-
.
|
|
20
|
-
.stat {
|
|
21
|
-
.statHead { display:flex; align-items:center; gap:
|
|
22
|
-
.statIcon { display:flex; align-items:center; justify-content:center; width:
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
.
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
.
|
|
35
|
-
.cardHead
|
|
27
|
+
.scope { color:var(--dsw-alias-label-tertiary); font-size:11px; margin:0 0 14px; }
|
|
28
|
+
.stats { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:8px; margin-bottom:16px; }
|
|
29
|
+
.stat { min-width:0; position:relative; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l1); border-radius:12px; padding:12px 14px 11px; box-shadow:0 1px 2px var(--dsw-alias-bg-mask-2); transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
|
|
30
|
+
.stat:hover { border-color:var(--dsw-alias-border-l2); box-shadow:0 14px 30px -22px var(--dsw-alias-bg-mask-1), 0 1px 2px var(--dsw-alias-bg-mask-2); transform:translateY(-1px); }
|
|
31
|
+
.statHead { display:flex; align-items:center; gap:5px; margin-bottom:6px; }
|
|
32
|
+
.statIcon { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:6px; color:var(--tone); background:color-mix(in srgb, var(--tone) 14%, transparent); flex-shrink:0; }
|
|
33
|
+
.statLabel { font-size:12px; font-weight:500; color:var(--dsw-alias-label-tertiary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
34
|
+
.statValue { display:block; font-size:21px; font-weight:650; letter-spacing:-0.4px; color:var(--dsw-alias-label-primary); white-space:nowrap; font-variant-numeric:tabular-nums; }
|
|
35
|
+
.statSub { display:block; margin-top:8px; padding-top:7px; border-top:1px solid var(--dsw-alias-border-l1); font-size:11px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
36
|
+
/* Accent hues: this plugin's tone scale, matching the dashboard cards' chart
|
|
37
|
+
* accents. The soft chip is a translucent mix of the hue itself, so it flips
|
|
38
|
+
* with the theme automatically; only the base hue needs a dark value (below). */
|
|
39
|
+
.toneBlue { --tone:#4d6bfe; }
|
|
40
|
+
.tonePurple { --tone:#975bf1; }
|
|
41
|
+
.toneGreen { --tone:#00b96b; }
|
|
42
|
+
.toneOrange { --tone:#e07b39; }
|
|
43
|
+
.toneTeal { --tone:#0d8a8a; }
|
|
44
|
+
.card { min-width:0; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l2); border-radius:12px; margin-bottom:18px; overflow:hidden; }
|
|
45
|
+
.cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid var(--dsw-alias-border-l1); }
|
|
46
|
+
.cardHead h2 { font-size:14px; font-weight:600; margin:0; color:var(--dsw-alias-label-primary); }
|
|
36
47
|
.cardHead > span { display:flex; gap:7px; align-items:center; font-size:11px; }
|
|
37
|
-
.count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color
|
|
48
|
+
.count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-markdown-tag); border-radius:5px; margin-left:5px; font-weight:500; }
|
|
38
49
|
.mid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
|
|
39
50
|
.mid .card { height:100%; margin-bottom:0; }
|
|
40
51
|
.mid { margin-bottom:18px; }
|
|
41
52
|
.table { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }
|
|
42
|
-
.table th { color
|
|
43
|
-
.table td { padding:13px 5px; text-align:right; border-top:1px solid
|
|
53
|
+
.table th { color:var(--dsw-alias-label-tertiary); font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }
|
|
54
|
+
.table td { padding:13px 5px; text-align:right; border-top:1px solid var(--dsw-alias-border-l1); color:var(--dsw-alias-label-secondary); font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
44
55
|
.table th:first-child,.table td:first-child { text-align:left; padding-left:16px; }
|
|
45
56
|
.table th:last-child,.table td:last-child { padding-right:16px; }
|
|
46
57
|
.childName { width:28%; }
|
|
@@ -48,69 +59,71 @@
|
|
|
48
59
|
.modelName { width:30%; }
|
|
49
60
|
.modelRequests { width:11%; }
|
|
50
61
|
.modelHit { width:16%; }
|
|
51
|
-
.ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color
|
|
62
|
+
.ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--dsw-alias-label-primary-dimmed); }
|
|
52
63
|
/* By-model rows lead with the model's brand logo; the name keeps truncating
|
|
53
64
|
* inside the flexible remainder of the cell. */
|
|
54
65
|
.modelNameCell { display:flex; align-items:center; gap:6px; min-width:0; }
|
|
55
66
|
.modelNameCell .ellipsis { flex:1; min-width:0; }
|
|
56
67
|
/* Gray provider label under a model name shown by several rows (同名模型). */
|
|
57
|
-
.modelProvider { display:block; margin-top:2px; font-size:10px; color
|
|
68
|
+
.modelProvider { display:block; margin-top:2px; font-size:10px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
58
69
|
.number { font-variant-numeric:tabular-nums; }
|
|
59
70
|
.speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
|
|
60
|
-
.speedCell small { font-size:9px; color
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.action:
|
|
71
|
+
.speedCell small { font-size:9px; color:var(--dsw-alias-label-tertiary); }
|
|
72
|
+
/* Status accents ride the host state tokens; the running/live teal is this
|
|
73
|
+
* plugin's tone-5 accent (the host has no teal state token). */
|
|
74
|
+
.good { color:var(--dsw-alias-state-success-primary); }
|
|
75
|
+
.warn { color:var(--dsw-alias-state-warn-label); }
|
|
76
|
+
.bad { color:var(--dsw-alias-state-error-primary); }
|
|
77
|
+
.live { color:#0d8a8a; }
|
|
78
|
+
.muted { color:var(--dsw-alias-label-tertiary); }
|
|
79
|
+
.empty { margin:0; padding:28px 18px; color:var(--dsw-alias-label-tertiary); font-size:12px; text-align:center; }
|
|
80
|
+
.action { color:var(--dsw-alias-label-secondary); background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
|
|
81
|
+
.action:hover { color:var(--dsw-alias-label-link); }
|
|
82
|
+
.action:focus-visible,.turnHead:focus-visible,.refresh:focus-visible { outline:2px solid var(--dsw-alias-label-link); outline-offset:-2px; }
|
|
70
83
|
.turnList { padding:0 14px 14px; }
|
|
71
|
-
.turn { margin-top:10px; border:1px solid
|
|
72
|
-
.turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background
|
|
73
|
-
.chevron { color
|
|
84
|
+
.turn { margin-top:10px; border:1px solid var(--dsw-alias-border-l1); border-radius:9px; background:var(--dsw-alias-bg-layer-1); overflow:hidden; }
|
|
85
|
+
.turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:var(--dsw-alias-menu-group-header-fill); border:0; cursor:pointer; color:var(--dsw-alias-label-secondary); font:inherit; font-size:11px; }
|
|
86
|
+
.chevron { color:var(--dsw-alias-label-tertiary); transition:transform 0.2s; }
|
|
74
87
|
.rotated { transform:rotate(90deg); }
|
|
75
|
-
.turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color
|
|
88
|
+
.turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:var(--dsw-alias-label-primary-dimmed); }
|
|
76
89
|
/* Turn topic is the group title now (the page header owns the session name),
|
|
77
90
|
* so it carries the primary text color and truncates before the metrics. */
|
|
78
91
|
.turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
79
92
|
/* A subagent's turns look like this session's turns apart from the agent tag;
|
|
80
93
|
* the tag keeps the full session name in its tooltip. */
|
|
81
|
-
.turnAgent { flex:none; padding:1px 6px; border-radius:5px; background
|
|
94
|
+
.turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:var(--dsw-alias-interactive-bg-active); color:var(--dsw-alias-label-secondary); font-size:10px; font-weight:500; }
|
|
82
95
|
.turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
|
|
83
96
|
.turnSummary > span:last-child { margin-left:8px; }
|
|
84
|
-
.turnBody { border-top:1px solid
|
|
97
|
+
.turnBody { border-top:1px solid var(--dsw-alias-border-l1); }
|
|
85
98
|
.turnBody[hidden] { display:none; }
|
|
86
99
|
/* A request row is one line: time, what answered, and its metrics pinned to the
|
|
87
100
|
* row end. The metrics never wrap; they truncate with a tooltip before the head
|
|
88
101
|
* gives up its model name. */
|
|
89
|
-
.turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid
|
|
102
|
+
.turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid var(--dsw-alias-border-l1); }
|
|
90
103
|
.turnRequest:first-child { border-top:0; }
|
|
91
|
-
.turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color
|
|
92
|
-
.requestTime { color
|
|
104
|
+
.turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--dsw-alias-label-secondary); min-width:0; }
|
|
105
|
+
.requestTime { color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; flex-shrink:0; }
|
|
93
106
|
.statusDot { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }
|
|
94
107
|
/* Per-model brand logo: colors are baked into each logo's markup, only the
|
|
95
108
|
* layout contract lives here. Unknown models get the neutral gray fallback. */
|
|
96
109
|
.modelLogo { flex-shrink:0; }
|
|
97
|
-
.recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color
|
|
98
|
-
.routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color
|
|
99
|
-
.effort { white-space:nowrap; color
|
|
100
|
-
.recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color
|
|
101
|
-
.note { font-size:10px; line-height:1.7; color
|
|
102
|
-
.error { background
|
|
110
|
+
.recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dsw-alias-label-primary-dimmed); font-weight:500; }
|
|
111
|
+
.routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dsw-alias-label-tertiary); flex-shrink:1; }
|
|
112
|
+
.effort { white-space:nowrap; color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-markdown-tag); border-radius:5px; padding:1px 6px; }
|
|
113
|
+
.recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }
|
|
114
|
+
.note { font-size:10px; line-height:1.7; color:var(--dsw-alias-label-tertiary); margin:0; }
|
|
115
|
+
.error { background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color:var(--dsw-alias-state-error-primary); border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 35%, transparent); border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
|
|
103
116
|
@container (max-width:1050px) {
|
|
104
117
|
.mid { grid-template-columns:minmax(0,1fr); }
|
|
105
|
-
.
|
|
106
|
-
.statValue { font-size:23px; }
|
|
118
|
+
.stats { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
|
107
119
|
.table { font-size:12px; }
|
|
108
120
|
.childName { width:34%; }
|
|
109
121
|
}
|
|
110
122
|
@container (max-width:700px) {
|
|
111
123
|
.page { padding:18px 14px 24px; }
|
|
112
|
-
.
|
|
113
|
-
.
|
|
124
|
+
.stats { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
125
|
+
.stat { padding:10px 12px 9px; }
|
|
126
|
+
.statValue { font-size:18px; }
|
|
114
127
|
.table { font-size:10px; }
|
|
115
128
|
.table th,.table td { padding:10px 3px; }
|
|
116
129
|
.table th:first-child,.table td:first-child { padding-left:10px; }
|
|
@@ -128,4 +141,16 @@
|
|
|
128
141
|
}
|
|
129
142
|
@media (prefers-reduced-motion:reduce) {
|
|
130
143
|
.chevron { transition:none; }
|
|
144
|
+
.stat { transition:none; }
|
|
145
|
+
.stat:hover { transform:none; }
|
|
131
146
|
}
|
|
147
|
+
/* Dark theme: the host sets body[data-ds-dark-theme] and every alias token
|
|
148
|
+
* above re-resolves on its own; only this plugin's own accent hues need
|
|
149
|
+
* explicit dark values — brighter variants of the dashboard's dark chart
|
|
150
|
+
* accents, so chips and running states hold their weight on dark cards. */
|
|
151
|
+
body[data-ds-dark-theme] .toneBlue { --tone:#7d95ff; }
|
|
152
|
+
body[data-ds-dark-theme] .tonePurple { --tone:#b18cff; }
|
|
153
|
+
body[data-ds-dark-theme] .toneGreen { --tone:#2fbf7c; }
|
|
154
|
+
body[data-ds-dark-theme] .toneOrange { --tone:#e89a5c; }
|
|
155
|
+
body[data-ds-dark-theme] .toneTeal { --tone:#3bc5c5; }
|
|
156
|
+
body[data-ds-dark-theme] .live { color:#3bc5c5; }
|
|
@@ -5,10 +5,11 @@ import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
|
|
|
5
5
|
import { formatCompact, formatTokens, normalizeModelName, decodeRateOf } from './format.ts'
|
|
6
6
|
import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
|
|
7
7
|
import { useSessionDetail } from './use-session-detail.ts'
|
|
8
|
+
import { ContextWindowPanel } from './ContextWindowPanel.tsx'
|
|
8
9
|
import { ModelLogo } from './ModelLogo.tsx'
|
|
9
10
|
import css from './SessionUsageView.module.css'
|
|
10
11
|
|
|
11
|
-
type T = (key: SessionUsageKey) => string
|
|
12
|
+
type T = (key: SessionUsageKey, params?: Record<string, string>) => string
|
|
12
13
|
type Row = SessionUsageDetail['records'][number]
|
|
13
14
|
export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
|
|
14
15
|
|
|
@@ -45,19 +46,15 @@ const icons = {
|
|
|
45
46
|
speed: 'M13 2 4 14h7l-1 8 9-12h-7l1-8Z',
|
|
46
47
|
input: 'M12 3v12M7 10l5 5 5-5M4 21h16',
|
|
47
48
|
output: 'M12 21V9M7 14l5-5 5 5M4 3h16',
|
|
48
|
-
read: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7ZM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
|
|
49
|
-
write: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5Z',
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
function Stat({ tone, name, label, value, sub, title }: { tone: string; name: keyof typeof icons; label: string; value: string | JSX.Element; sub?: string; title?: string }): JSX.Element {
|
|
53
52
|
const icon = icons[name]
|
|
54
53
|
return <article className={`${css.stat} ${tone}`}>
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
</svg>
|
|
60
|
-
<div className={css.statHead}><span className={css.statIcon}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span><span className={css.statLabel}>{label}</span></div>
|
|
54
|
+
<div className={css.statHead}>
|
|
55
|
+
<span className={css.statIcon}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span>
|
|
56
|
+
<span className={css.statLabel}>{label}</span>
|
|
57
|
+
</div>
|
|
61
58
|
<strong className={css.statValue} title={title}>{value}</strong>
|
|
62
59
|
{sub !== undefined && <span className={css.statSub}>{sub}</span>}
|
|
63
60
|
</article>
|
|
@@ -68,18 +65,12 @@ function Metrics({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
|
|
|
68
65
|
const failed = detail.records.filter(row => row.status === 'failed').length
|
|
69
66
|
const failRate = detail.requests > 0 ? (failed / detail.requests * 100).toFixed(1) : '0.0'
|
|
70
67
|
return <div className={css.stats}>
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<div className={css.statRow}>
|
|
78
|
-
<Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
|
|
79
|
-
<Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
|
|
80
|
-
<Stat tone={css.tonePurple} name="read" label={t('readFull')} value={formatCompact(detail.totals.cacheReadTokens)} title={formatTokens(detail.totals.cacheReadTokens)} sub={formatTokens(detail.totals.cacheReadTokens)} />
|
|
81
|
-
<Stat tone={css.tonePink} name="write" label={t('writeFull')} value={formatCompact(detail.totals.cacheWriteTokens)} title={formatTokens(detail.totals.cacheWriteTokens)} sub={formatTokens(detail.totals.cacheWriteTokens)} />
|
|
82
|
-
</div>
|
|
68
|
+
<Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
|
|
69
|
+
<Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
|
|
70
|
+
<Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
|
|
71
|
+
<Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
|
|
72
|
+
<Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
|
|
73
|
+
<Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
|
|
83
74
|
</div>
|
|
84
75
|
}
|
|
85
76
|
|
|
@@ -150,6 +141,11 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
|
|
|
150
141
|
}
|
|
151
142
|
group.rows.push(record)
|
|
152
143
|
}
|
|
144
|
+
// Newest turn on top: groups order by the time of their most recent request,
|
|
145
|
+
// not by first appearance, so the live work leads and finished history trails
|
|
146
|
+
// (records arrive root-session first, subagents after, regardless of time).
|
|
147
|
+
const latest = (group: { rows: Row[] }): number => group.rows.reduce((max, row) => Math.max(max, row.startedAt), 0)
|
|
148
|
+
groups.sort((a, b) => latest(b) - latest(a))
|
|
153
149
|
return <section className={css.card}><header className={css.cardHead}><h2>{t('records')} <span className={css.count}>{detail.records.length}</span></h2><button type="button" className={css.action} onClick={toggleAll}>{t(allOpen ? 'collapse' : 'expand')}</button></header>
|
|
154
150
|
<div className={css.turnList}>{groups.map(group => {
|
|
155
151
|
const open = allOpen !== opened.has(group.key)
|
|
@@ -175,8 +171,8 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
|
|
|
175
171
|
</span>
|
|
176
172
|
</button>
|
|
177
173
|
<div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
|
|
178
|
-
{/*
|
|
179
|
-
|
|
174
|
+
{/* Newest first inside and out: every turn lists its newest request
|
|
175
|
+
first, and turn groups themselves lead with the latest turn. */}
|
|
180
176
|
{group.rows.slice().reverse().map(record => {
|
|
181
177
|
const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
|
|
182
178
|
const billed = billedTotal(record.totals)
|
|
@@ -207,8 +203,16 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
|
|
|
207
203
|
}
|
|
208
204
|
|
|
209
205
|
/** Pure presentation export also used by offline visual regression fixtures. */
|
|
210
|
-
export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
|
|
211
|
-
return
|
|
206
|
+
export function SessionUsageContent({ detail, t, updatedAt }: { detail: SessionUsageDetail; t: T; updatedAt?: number | null }): JSX.Element {
|
|
207
|
+
return <>
|
|
208
|
+
<p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
|
|
209
|
+
{/* The context window sits between the per-subagent/model tables and the
|
|
210
|
+
request records: it describes what the next request carries right now,
|
|
211
|
+
while everything above is spent-token history and everything below is
|
|
212
|
+
per-turn detail. */}
|
|
213
|
+
{detail.context ? <ContextWindowPanel context={detail.context} t={t} updatedAt={updatedAt} /> : null}
|
|
214
|
+
<Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p>
|
|
215
|
+
</>
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
|
|
@@ -230,6 +234,6 @@ export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.E
|
|
|
230
234
|
</header>
|
|
231
235
|
{loading && <p className={css.empty} role="status">{t('loading')}</p>}
|
|
232
236
|
{error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={refresh}>{t('retry')}</button></div>}
|
|
233
|
-
{detail && <SessionUsageContent detail={detail} t={t} />}
|
|
237
|
+
{detail && <SessionUsageContent detail={detail} t={t} updatedAt={updatedAt} />}
|
|
234
238
|
</div></div>
|
|
235
239
|
}
|