dsh-token-total 1.0.4 → 1.0.6

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.
@@ -0,0 +1,29 @@
1
+ import { modelLogoMarkup, resolveModelLogo } from './model-logos-core.ts'
2
+
3
+ /** Neutral glyph for models no bundled brand matches: a soft four-point AI
4
+ * spark in the panel's muted gray, so an unknown model reads as "unbranded"
5
+ * rather than as some other vendor. */
6
+ const FALLBACK = '<path d="M12 1.6l2.14 5.96a2 2 0 0 0 1.22 1.22l5.96 2.14-5.96 2.14a2 2 0 0 0-1.22 1.22L12 20.24l-2.14-5.96a2 2 0 0 0-1.22-1.22L2.68 10.92l5.96-2.14a2 2 0 0 0 1.22-1.22L12 1.6z" fill="#9aa3b3"/>'
7
+
8
+ export interface ModelLogoProps {
9
+ /** Raw model id; may itself be a "provider/model" ref. */
10
+ model: string
11
+ /** Resolved model display name (the by-model row's label) — matched with
12
+ * the raw id, so an opaque route id beside a brandful name still brands. */
13
+ name?: string
14
+ /** Provider route id — a secondary match signal for opaque model ids. */
15
+ provider?: string
16
+ /** Resolved provider display names (the by-model row's split), matched last. */
17
+ providers?: ReadonlyArray<string>
18
+ /** Rendered square size; the record rows read at 13px. */
19
+ size?: number
20
+ className?: string
21
+ }
22
+
23
+ /** One model's brand logo inline. The markup is a bundled constant (never
24
+ * request data) rendered as-is so each brand keeps its own gradients,
25
+ * circles and multi-path fills; ids inside stay unique across brands. */
26
+ export function ModelLogo({ model, name, provider, providers, size = 13, className }: ModelLogoProps): JSX.Element {
27
+ const markup = modelLogoMarkup(resolveModelLogo(provider, model, name, providers)) ?? FALLBACK
28
+ return <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" className={className} dangerouslySetInnerHTML={{ __html: markup }} />
29
+ }
@@ -0,0 +1,122 @@
1
+ /* A light, self-contained Usage surface. Tables fit the available width:
2
+ * no horizontal scroll containers, fixed column contracts, titles for names.
3
+ * Stat cards follow the reference dashboard: tinted left edge + icon chip,
4
+ * label, big value, small subtitle. Request log folds by conversation turn. */
5
+ .scroll { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }
6
+ .page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:13px; }
7
+ .page * { box-sizing:border-box; }
8
+ .scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
9
+ .stats { display:grid; gap:12px; margin-bottom:18px; }
10
+ .statRow { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
11
+ .stat { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }
12
+ .statHead { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
13
+ .statIcon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }
14
+ .statMark { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }
15
+ .statLabel { font-size:12px; color:#7d8495; }
16
+ .statValue { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }
17
+ .statSub { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18
+ .toneBlue { --tone:#4f79f6; --tone-soft:#eaf0fe; }
19
+ .tonePurple { --tone:#8b5cf6; --tone-soft:#f1ebfe; }
20
+ .toneGreen { --tone:#18a97b; --tone-soft:#e5f6f0; }
21
+ .toneOrange { --tone:#e8892c; --tone-soft:#fcf1e4; }
22
+ .toneTeal { --tone:#16a4b8; --tone-soft:#e4f5f8; }
23
+ .tonePink { --tone:#d9538c; --tone-soft:#fbeaf1; }
24
+ .card { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }
25
+ .cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }
26
+ .cardHead h2 { font-size:14px; font-weight:600; margin:0; }
27
+ .cardHead > span { display:flex; gap:7px; align-items:center; font-size:11px; }
28
+ .count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:#8b93a3; background:#f3f5f8; border-radius:5px; margin-left:5px; font-weight:500; }
29
+ .mid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
30
+ .mid .card { height:100%; margin-bottom:0; }
31
+ .mid { margin-bottom:18px; }
32
+ .table { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }
33
+ .table th { color:#929aaa; font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }
34
+ .table td { padding:13px 5px; text-align:right; border-top:1px solid #eef0f4; color:#626c80; font-variant-numeric:tabular-nums; white-space:nowrap; }
35
+ .table th:first-child,.table td:first-child { text-align:left; padding-left:16px; }
36
+ .table th:last-child,.table td:last-child { padding-right:16px; }
37
+ .childName { width:28%; }
38
+ .childRequests { width:11%; }
39
+ .modelName { width:30%; }
40
+ .modelRequests { width:11%; }
41
+ .modelHit { width:16%; }
42
+ .ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }
43
+ /* By-model rows lead with the model's brand logo; the name keeps truncating
44
+ * inside the flexible remainder of the cell. */
45
+ .modelNameCell { display:flex; align-items:center; gap:6px; min-width:0; }
46
+ .modelNameCell .ellipsis { flex:1; min-width:0; }
47
+ /* Gray provider label under a model name shown by several rows (同名模型). */
48
+ .modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
49
+ .number { font-variant-numeric:tabular-nums; }
50
+ .speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
51
+ .speedCell small { font-size:9px; color:#929aaa; }
52
+ .good { color:#21a17b; }
53
+ .warn { color:#d69a21; }
54
+ .bad { color:#e06571; }
55
+ .live { color:#169bb3; }
56
+ .muted { color:#939aaa; }
57
+ .empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
58
+ .action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
59
+ .action:hover { color:#279cb6; }
60
+ .action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
61
+ .turnList { padding:0 14px 14px; }
62
+ .turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
63
+ .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:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }
64
+ .chevron { color:#99a1af; transition:transform 0.2s; }
65
+ .rotated { transform:rotate(90deg); }
66
+ .turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }
67
+ /* Turn topic is the group title now (the page header owns the session name),
68
+ * so it carries the primary text color and truncates before the metrics. */
69
+ .turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
70
+ /* A subagent's turns look like this session's turns apart from the agent tag;
71
+ * the tag keeps the full session name in its tooltip. */
72
+ .turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }
73
+ .turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
74
+ .turnSummary > span:last-child { margin-left:8px; }
75
+ .turnBody { border-top:1px solid #eef0f4; }
76
+ .turnBody[hidden] { display:none; }
77
+ /* A request row is one line: time, what answered, and its metrics pinned to the
78
+ * row end. The metrics never wrap; they truncate with a tooltip before the head
79
+ * gives up its model name. */
80
+ .turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }
81
+ .turnRequest:first-child { border-top:0; }
82
+ .turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
83
+ .requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
84
+ .statusDot { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }
85
+ /* Per-model brand logo: colors are baked into each logo's markup, only the
86
+ * layout contract lives here. Unknown models get the neutral gray fallback. */
87
+ .modelLogo { flex-shrink:0; }
88
+ .recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
89
+ .routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
90
+ .effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
91
+ .recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:#949cab; font-variant-numeric:tabular-nums; }
92
+ .note { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }
93
+ .error { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
94
+ @container (max-width:1050px) {
95
+ .mid { grid-template-columns:minmax(0,1fr); }
96
+ .statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
97
+ .statValue { font-size:23px; }
98
+ .table { font-size:12px; }
99
+ .childName { width:34%; }
100
+ }
101
+ @container (max-width:700px) {
102
+ .page { padding:18px 14px 24px; }
103
+ .stat { padding:12px 12px 11px; }
104
+ .statValue { font-size:20px; }
105
+ .table { font-size:10px; }
106
+ .table th,.table td { padding:10px 3px; }
107
+ .table th:first-child,.table td:first-child { padding-left:10px; }
108
+ .table th:last-child,.table td:last-child { padding-right:10px; }
109
+ .childName { width:25%; }
110
+ .childRequests { width:11%; }
111
+ .modelName { width:25%; }
112
+ .modelRequests { width:11%; }
113
+ .modelHit { width:17%; }
114
+ .turnHead { gap:8px; padding:11px 10px; }
115
+ .turnSummary { grid-column:2; font-size:10px; }
116
+ .turnRequest { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }
117
+ .turnRequestHead { gap:6px; }
118
+ .routeText { display:none; }
119
+ }
120
+ @media (prefers-reduced-motion:reduce) {
121
+ .chevron { transition:none; }
122
+ }
@@ -0,0 +1,216 @@
1
+ import { useState } from 'react'
2
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
3
+ import type { SessionUsageDetail, SessionUsageTotals } from '../wire.ts'
4
+ import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
5
+ import { formatCompact, formatTokens, normalizeModelName } from './format.ts'
6
+ import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
7
+ import { useSessionDetail } from './use-session-detail.ts'
8
+ import { ModelLogo } from './ModelLogo.tsx'
9
+ import css from './SessionUsageView.module.css'
10
+
11
+ type T = (key: SessionUsageKey) => string
12
+ type Row = SessionUsageDetail['records'][number]
13
+ export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
14
+
15
+ const seconds = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : '—'
16
+ // Request-log style metrics follow the reference log: seconds without trailing
17
+ // ".0", short lowercase tokens (80.0k / 1.5M), integer percentages.
18
+ const secText = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? String(Math.round(ms / 100) / 10) : '—'
19
+ const tokShort = (n: number): string => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
20
+ const pctInt = (rate: number | null): string => rate === null ? '—' : `${Math.round(rate)}%`
21
+ const speed = (n: number | null): string => n !== null && Number.isFinite(n) ? `${Math.round(n)} tok/s` : '—'
22
+ const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
23
+ const modelShort = (ref: string): string => ref.includes('/') ? ref.slice(ref.indexOf('/') + 1) : ref
24
+ const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
25
+ const sum = (rows: Row[]): SessionUsageTotals => rows.reduce((acc, r) => ({
26
+ uncachedInputTokens: acc.uncachedInputTokens + r.totals.uncachedInputTokens,
27
+ outputTokens: acc.outputTokens + r.totals.outputTokens,
28
+ cacheReadTokens: acc.cacheReadTokens + r.totals.cacheReadTokens,
29
+ cacheWriteTokens: acc.cacheWriteTokens + r.totals.cacheWriteTokens,
30
+ }), zero())
31
+
32
+ function Token({ n }: { n: number }): JSX.Element {
33
+ return <span className={css.number} title={formatTokens(n)}>{formatCompact(n)}</span>
34
+ }
35
+
36
+ function Hit({ totals }: { totals: SessionUsageTotals }): JSX.Element {
37
+ const rate = cacheHitPercentOf(totals)
38
+ return <span className={rate === null ? css.muted : rate >= 80 ? css.good : rate >= 50 ? css.warn : css.bad}>{rate === null ? '—' : `${rate.toFixed(1)}%`}</span>
39
+ }
40
+
41
+ const icons = {
42
+ requests: 'M4 9h16M4 15h16M10 3 8 21M16 3l-2 18',
43
+ tokens: 'M4 8c0-2.2 3.6-4 8-4s8 1.8 8 4-3.6 4-8 4-8-1.8-8-4Zm0 0v8c0 2.2 3.6 4 8 4s8-1.8 8-4V8',
44
+ hit: 'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
45
+ speed: 'M13 2 4 14h7l-1 8 9-12h-7l1-8Z',
46
+ input: 'M12 3v12M7 10l5 5 5-5M4 21h16',
47
+ 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
+ }
51
+
52
+ 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
+ const icon = icons[name]
54
+ return <article className={`${css.stat} ${tone}`}>
55
+ {/* Oversized gradient watermark filling the card's empty right side. */}
56
+ <svg className={css.statMark} viewBox="0 0 24 24" aria-hidden="true">
57
+ <defs><linearGradient id={`usage-mark-${name}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity="0.3" /><stop offset="1" stopColor="currentColor" stopOpacity="0.03" /></linearGradient></defs>
58
+ <path d={icon} fill={`url(#usage-mark-${name})`} stroke={`url(#usage-mark-${name})`} strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
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>
61
+ <strong className={css.statValue} title={title}>{value}</strong>
62
+ {sub !== undefined && <span className={css.statSub}>{sub}</span>}
63
+ </article>
64
+ }
65
+
66
+ function Metrics({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
67
+ const billed = billedTotal(detail.totals)
68
+ const failed = detail.records.filter(row => row.status === 'failed').length
69
+ const failRate = detail.requests > 0 ? (failed / detail.requests * 100).toFixed(1) : '0.0'
70
+ return <div className={css.stats}>
71
+ <div className={css.statRow}>
72
+ <Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
73
+ <Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
74
+ <Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
75
+ <Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
76
+ </div>
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>
83
+ </div>
84
+ }
85
+
86
+ function Tables({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
87
+ // The by-model card labels rows with the model selector's display name, never
88
+ // the route id (which stays in the tooltip). When two rows end up sharing one
89
+ // display name, each of those rows additionally shows its provider in gray
90
+ // below the name so they stay tellable apart.
91
+ const nameCounts = new Map<string, number>()
92
+ for (const row of detail.models) {
93
+ const key = normalizeModelName(row.name?.trim() || row.model)
94
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1)
95
+ }
96
+ return <div className={css.mid}>
97
+ <section className={css.card}><header className={css.cardHead}><h2>{t('children')} <span className={css.count}>{detail.subagents.length}</span></h2></header>
98
+ <table className={`${css.table} ${css.childTable}`}><colgroup><col className={css.childName} /><col className={css.childRequests} /><col /><col /><col /><col /></colgroup>
99
+ <thead><tr><th>{t('session')}</th><th>{t('requests')}</th><th>{t('tokens')}</th><th>{t('hit')}</th><th>{t('first')}</th><th>{t('speed')}</th></tr></thead>
100
+ <tbody>{detail.subagents.map(row => <tr key={row.sessionId}><td><span className={css.ellipsis} title={row.name || row.sessionId}>{row.name || row.sessionId}</span></td><td>{formatTokens(row.requests)}</td><td><Token n={billedTotal(row.totals)} /></td><td><Hit totals={row.totals} /></td><td>{seconds(row.averageFirstTokenMs)}</td><td><span className={css.speedCell}>{row.tokensPerSecond === null ? '—' : <>{Math.round(row.tokensPerSecond)}<small>tok/s</small></>}</span></td></tr>)}</tbody>
101
+ </table>{detail.subagents.length === 0 && <p className={css.empty}>{t('noChildren')}</p>}
102
+ </section>
103
+ <section className={css.card}><header className={css.cardHead}><h2>{t('models')} <span className={css.count}>{detail.models.length}</span></h2></header>
104
+ <table className={`${css.table} ${css.modelTable}`}><colgroup><col className={css.modelName} /><col className={css.modelRequests} /><col /><col /><col /><col /><col className={css.modelHit} /></colgroup>
105
+ <thead><tr><th>{t('model')}</th><th>{t('requests')}</th><th>{t('input')}</th><th>{t('output')}</th><th>{t('read')}</th><th>{t('write')}</th><th>{t('hit')}</th></tr></thead>
106
+ <tbody>{detail.models.map(row => {
107
+ const name = row.name?.trim() || row.model
108
+ const providers = row.providers ?? []
109
+ const shared = (nameCounts.get(normalizeModelName(name)) ?? 0) > 1
110
+ return <tr key={row.model}>
111
+ <td><span className={css.modelNameCell}><ModelLogo model={row.model} name={row.name} providers={row.providers} className={css.modelLogo} /><span className={css.ellipsis} title={row.model}>{name}</span></span>
112
+ {shared && providers.length > 0 ? <small className={css.modelProvider} title={providers.join(' / ')}>{providers.join(' / ')}</small> : null}
113
+ </td>
114
+ <td>{formatTokens(row.requests)}</td><td><Token n={row.totals.uncachedInputTokens} /></td><td><Token n={row.totals.outputTokens} /></td><td><Token n={row.totals.cacheReadTokens} /></td><td><Token n={row.totals.cacheWriteTokens} /></td><td><Hit totals={row.totals} /></td>
115
+ </tr>
116
+ })}</tbody>
117
+ </table>{detail.models.length === 0 && <p className={css.empty}>{t('noModels')}</p>}
118
+ </section>
119
+ </div>
120
+ }
121
+
122
+ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
123
+ const [opened, setOpened] = useState<Set<string>>(() => new Set())
124
+ const [allOpen, setAllOpen] = useState(false)
125
+ const toggleAll = (): void => { setAllOpen(!allOpen); setOpened(new Set()) }
126
+ const toggle = (id: string): void => setOpened(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next })
127
+ const providerNames = new Map(detail.routes.map(route => [route.provider, route.name]))
128
+ // Fold by conversation turn: one collapsible group per session turn, holding
129
+ // every request of that turn. A group is titled by what the turn did (the
130
+ // turn's first real user prompt, turnName from the backend), never by the
131
+ // session name: the page header already names this session, so repeating it on
132
+ // every row would only crowd out the topic. A subagent's turn gets a compact
133
+ // "subagent" tag instead, with the full session name kept in its tooltip —
134
+ // that tag is the only thing distinguishing it from this session's own turns.
135
+ const groups: Array<{ key: string; turn: number; name: string; topic: string; main: boolean; rows: Row[] }> = []
136
+ const index = new Map<string, { key: string; turn: number; name: string; topic: string; main: boolean; rows: Row[] }>()
137
+ for (const record of detail.records) {
138
+ const key = `${record.sessionId}:${record.turn}`
139
+ let group = index.get(key)
140
+ if (!group) {
141
+ group = {
142
+ key,
143
+ turn: record.turn,
144
+ name: record.sessionName || record.sessionId,
145
+ topic: record.turnName || `${t('turn')} ${record.turn}`,
146
+ main: record.sessionId === detail.sessionId,
147
+ rows: [],
148
+ }
149
+ index.set(key, group); groups.push(group)
150
+ }
151
+ group.rows.push(record)
152
+ }
153
+ 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
+ <div className={css.turnList}>{groups.map(group => {
155
+ const open = allOpen !== opened.has(group.key)
156
+ const totals = sum(group.rows)
157
+ const billed = billedTotal(totals)
158
+ const running = group.rows.filter(row => row.status === 'running').length
159
+ const rates = group.rows.map(row => row.tokensPerSecond).filter((n): n is number => n !== null)
160
+ const avgRate = rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : null
161
+ return <article key={group.key} className={css.turn}>
162
+ <button type="button" className={css.turnHead} onClick={() => toggle(group.key)} aria-expanded={open} aria-controls={`usage-turn-${encodeURIComponent(group.key)}`}>
163
+ <svg className={`${css.chevron} ${open ? css.rotated : ''}`} 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>
164
+ <span className={css.turnTitle}>
165
+ {group.main ? null : <span className={css.turnAgent} title={group.name}>{t('children')}</span>}
166
+ <span className={css.turnTopic} title={group.turn === 0 ? t('compaction') : group.topic}>{group.turn === 0 ? t('compaction') : group.topic}</span>
167
+ </span>
168
+ <span className={css.turnSummary}>
169
+ <span>{group.rows.length} {t('reqTimes')} · <span className={css.number} title={formatTokens(billed)}>{tokShort(billed)}</span> {t('tokenWord')}{running > 0 ? ` · ${running} ${t('inProgress')}` : ''}</span>
170
+ <span>{t('avgCache')} {pctInt(cacheHitPercentOf(totals))}</span>
171
+ <span>{t('avgRate')} {avgRate === null ? '—' : avgRate} {t('perSec')}</span>
172
+ </span>
173
+ </button>
174
+ <div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
175
+ {group.rows.map(record => {
176
+ const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
177
+ const billed = billedTotal(record.totals)
178
+ const hit = cacheHitPercentOf(record.totals)
179
+ const meta: Array<{ text: string; title?: string }> = []
180
+ if (duration !== null) meta.push({ text: `${secText(duration)} ${t('sec')}` })
181
+ if (record.firstTokenMs !== null) meta.push({ text: `${t('firstWait')} ${secText(record.firstTokenMs)} ${t('sec')}` })
182
+ meta.push({ text: `${tokShort(billed)} ${t('tokenWord')}`, title: formatTokens(billed) })
183
+ if (hit !== null) meta.push({ text: `${t('cacheShort')} ${pctInt(hit)}` })
184
+ if (record.tokensPerSecond !== null) meta.push({ text: `${Math.round(record.tokensPerSecond)} ${t('perSec')}` })
185
+ const statusText = record.status === 'running' ? t('answering') : record.status === 'completed' ? '' : t(record.status)
186
+ return <div key={record.id} className={css.turnRequest}>
187
+ <span className={css.requestTime}>{clock(record.startedAt)}</span>
188
+ <div className={css.turnRequestHead}>
189
+ <span className={`${css.statusDot} ${record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : record.status === 'interrupted' ? css.muted : css.good}`} />
190
+ <span className={css.recordModel} title={`${record.provider} / ${record.model}`}><ModelLogo provider={record.provider} model={record.model} className={css.modelLogo} />{record.model || '—'}</span>
191
+ <span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
192
+ {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
193
+ {record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
194
+ </div>
195
+ {record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
196
+ </div>
197
+ })}
198
+ </div>
199
+ </article>
200
+ })}</div>{detail.records.length === 0 && <p className={css.empty}>{t('empty')}</p>}
201
+ </section>
202
+ }
203
+
204
+ /** Pure presentation export also used by offline visual regression fixtures. */
205
+ export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
206
+ return <><p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} /><Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p></>
207
+ }
208
+
209
+ export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
210
+ const { detail, loading, error, retry } = useSessionDetail(sessionId)
211
+ return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
212
+ {loading && <p className={css.empty} role="status">{t('loading')}</p>}
213
+ {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
214
+ {detail && <SessionUsageContent detail={detail} t={t} />}
215
+ </div></div>
216
+ }
@@ -670,30 +670,36 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
670
670
  const maxTotal = Math.max(1, ...visible.map((d) => d.total))
671
671
  const ticks = niceTicks(maxTotal, 4)
672
672
  // What the X axis reads as depends on the WINDOW, not just the granularity:
673
- // a multi-day hourly span (3天) is a DATE axis — one short date per day, at
674
- // the day's first bucket, times would be noise across three day-clusters;
675
- // a single-day hourly span (今日) is a CLOCK axis — the date is constant and
676
- // would only repeat. Daily ranges stay date labels.
673
+ // a multi-day hourly span (3天) is a DATE axis — one short date per day,
674
+ // centred over that day's bar cluster, times would be noise across three
675
+ // day-clusters; a single-day hourly span (今日) is a CLOCK axis — the date is
676
+ // constant and would only repeat. Daily ranges stay date labels.
677
677
  const dayCount = new Set(visible.map((d) => d.day.slice(0, 10))).size
678
678
  const multiDayHourly = granularity === 'hour' && dayCount > 1
679
679
  const axisMode: 'day' | 'hour' = multiDayHourly ? 'day' : granularity
680
- // Label buckets: day starts on multi-day hourly, every bucket otherwise.
681
- const labelCandidates: number[] = []
680
+ // Label groups: on a multi-day hourly axis each DAY is one group whose label
681
+ // sits at the MIDDLE of that day's bars (centred on the day, not left-
682
+ // aligned to its first bar); otherwise every bucket is its own group and
683
+ // the label sits on its bar.
684
+ const labelGroups: Array<{ start: number; end: number }> = []
682
685
  for (let i = 0; i < n; i++) {
683
- if (!multiDayHourly || i === 0 || visible[i].day.slice(0, 10) !== visible[i - 1].day.slice(0, 10)) labelCandidates.push(i)
686
+ const last = labelGroups[labelGroups.length - 1]
687
+ if (multiDayHourly && last && visible[last.end].day.slice(0, 10) === visible[i].day.slice(0, 10)) last.end = i
688
+ else labelGroups.push({ start: i, end: i })
684
689
  }
685
- // Candidates are then thinned by POSITION, walking from the right edge: the
686
- // rightmost candidate always keeps its label, an earlier one is kept only
687
- // when it clears the previously kept label by the label width. (A fixed
688
- // "every Nth" count plus a forced last label drew two labels on top of
689
- // each other — the "18:00/23:00" overlap.) Both label shapes are ~30px.
690
+ // Groups are then thinned by POSITION, walking from the right edge: the
691
+ // rightmost group always keeps its label, an earlier one is kept only when
692
+ // it clears the previously kept label by the label width. (A fixed "every
693
+ // Nth" count plus a forced last label drew two labels on top of each other
694
+ // — the "18:00/23:00" overlap.) Both label shapes are ~30px.
690
695
  const labelMinGap = 42
691
- const labeledIdx = new Set<number>()
696
+ const labeledGroups = new Set<number>()
692
697
  let lastLabelX = Number.POSITIVE_INFINITY
693
- for (let k = labelCandidates.length - 1; k >= 0; k--) {
694
- const x = padL + barHalf + labelCandidates[k] * step
698
+ for (let k = labelGroups.length - 1; k >= 0; k--) {
699
+ const g = labelGroups[k]!
700
+ const x = padL + barHalf + ((g.start + g.end) / 2) * step
695
701
  if (lastLabelX - x >= labelMinGap) {
696
- labeledIdx.add(labelCandidates[k])
702
+ labeledGroups.add(k)
697
703
  lastLabelX = x
698
704
  }
699
705
  }
@@ -776,12 +782,18 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
776
782
  }}
777
783
  onMouseLeave={() => setTip(null)}
778
784
  />
779
- {labeledIdx.has(i) && (
780
- <text className={css.axis} x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{bucketAxisLabel(d.day, axisMode)}</text>
781
- )}
782
785
  </g>
783
786
  )
784
787
  })}
788
+ {labelGroups.map((g, k) => labeledGroups.has(k) && (
789
+ <text
790
+ key={`axis-${k}`}
791
+ className={css.axis}
792
+ x={padL + barHalf + ((g.start + g.end) / 2) * step}
793
+ y={H - 8}
794
+ textAnchor="middle"
795
+ >{bucketAxisLabel(visible[g.start].day, axisMode)}</text>
796
+ ))}
785
797
  <path className={css.trend} d={trendPath} fill="none" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
786
798
  {trendTipPt && (
787
799
  <circle className={css.trendDot} cx={trendTipPt.x} cy={trendTipPt.y} r={4} />
package/src/client/api.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * prefix-stripping proxy; the host's own route key stays absolute
12
12
  * (`/token-total/api` in src/routes.ts), because only the browser half is relative.
13
13
  */
14
- import type { BackfillStatus, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
14
+ import type { BackfillStatus, SessionUsageDetail, SessionUsageTree, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
15
15
 
16
16
  /** One wire failure. */
17
17
  export class UsageApiError extends Error {
@@ -28,13 +28,14 @@ interface WireResponse<T> {
28
28
  value: T
29
29
  }
30
30
 
31
- async function post<T>(method: string, body: unknown): Promise<T> {
31
+ async function post<T>(method: string, body: unknown, signal?: AbortSignal): Promise<T> {
32
32
  let res: Response
33
33
  try {
34
34
  res = await fetch(`token-total/api/${method}`, {
35
35
  method: 'POST',
36
36
  headers: { 'content-type': 'application/json' },
37
37
  body: body === undefined ? undefined : JSON.stringify(body),
38
+ signal,
38
39
  })
39
40
  } catch (err) {
40
41
  throw new UsageApiError('network', err instanceof Error ? err.message : String(err))
@@ -73,3 +74,8 @@ export async function rebuildStats(): Promise<BackfillStatus> {
73
74
  export async function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree> {
74
75
  return post<SessionUsageTree>('session', { sessionId })
75
76
  }
77
+
78
+ /** Log-owned requests and live route states for the per-session Usage view. */
79
+ export async function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail> {
80
+ return post<SessionUsageDetail>('session-detail', { sessionId }, signal)
81
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  // Shared with the host half (src/shared.ts has no Node/DOM types, so the
12
12
  // client bundle may import it at runtime).
13
- export { providerOf, modelNameOf, daysInRange as daysBetween } from '../shared.ts'
13
+ export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts'
14
14
 
15
15
  /** Exact token counts with thousands separators — no 万/亿/k/M units, the
16
16
  * user reads the precise number (long values shrink to fit via FitText).
@@ -62,7 +62,9 @@ export function indexOfDay(day: string): number {
62
62
  }
63
63
 
64
64
  export function shortDay(day: string): string {
65
- const parts = day.split('-')
65
+ // Bucket keys may carry an hour suffix ("YYYY-MM-DDTHH"); only the date
66
+ // prefix is a date — Number('22T14') is NaN and used to render "10/NaN".
67
+ const parts = day.slice(0, 10).split('-')
66
68
  const m = parts[1]
67
69
  const d = parts[2]
68
70
  return `${Number(m)}/${Number(d)}`
@@ -38,6 +38,8 @@ import { ContextUsageEntry, CONTEXT_LOCALE_NS, CONTEXT_LOCALES } from './Context
38
38
  import { installContextTakeover } from './context-takeover.ts'
39
39
  import { LOCALE_NS, en, zh, zhTW, type TokenTotalKey } from './locales.ts'
40
40
  import { isCompatibleGlass, whenGlassReady } from './glass.ts'
41
+ import { SessionUsageView } from './SessionUsageView.tsx'
42
+ import { SESSION_USAGE_NS, usageEn, usageZh, usageZhTW, type SessionUsageKey } from './session-usage-locales.ts'
41
43
  import css from './TokenTotalPanel.module.css'
42
44
 
43
45
  /** This bundle's npm package name — the key the Plugins page matches on. */
@@ -77,6 +79,7 @@ export type TokenTotalPanelPageProps = PropsLocale<typeof LOCALE_NS> & {
77
79
  declare module '@deepseek-ai/dsh-client-ui-slots' {
78
80
  interface LocaleNamespaceMap {
79
81
  'tokenTotal': TokenTotalKey
82
+ 'sessionUsage': SessionUsageKey
80
83
  }
81
84
  }
82
85
 
@@ -172,7 +175,12 @@ export function apply(ctx: Context): void {
172
175
  const offZhTw = ctx.locale.register(LOCALE_NS, 'zh-TW', zhTW)
173
176
  const offContext = Object.entries(CONTEXT_LOCALES).map(([locale, dict]) =>
174
177
  ctx.locale.register(CONTEXT_LOCALE_NS, locale, dict as Record<string, string>))
175
- return () => { offZh(); offEn(); offZhTw(); for (const off of offContext) off() }
178
+ const offSession = [
179
+ ctx.locale.register(SESSION_USAGE_NS, 'zh', usageZh),
180
+ ctx.locale.register(SESSION_USAGE_NS, 'en', usageEn),
181
+ ctx.locale.register(SESSION_USAGE_NS, 'zh-TW', usageZhTW),
182
+ ]
183
+ return () => { offZh(); offEn(); offZhTw(); for (const off of [...offContext, ...offSession]) off() }
176
184
  }, 'dsh-token-total: dictionaries')
177
185
 
178
186
  // Join the background plugin's frosted-glass sheet when it is installed,
@@ -274,6 +282,17 @@ export function apply(ctx: Context): void {
274
282
  locale: LOCALE_NS,
275
283
  }, StatsPanelIcon))
276
284
 
285
+ // Native session-scoped View: the host owns its tab, selection and composer.
286
+ // Waiting for declaration keeps older hosts without this seat harmless.
287
+ const sessionT = ctx.locale.bind(SESSION_USAGE_NS)
288
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
289
+ name: 'conversation.view',
290
+ id: 'token-total-usage',
291
+ order: 20,
292
+ label: () => sessionT('tab'),
293
+ locale: SESSION_USAGE_NS,
294
+ }, SessionUsageView))
295
+
277
296
  // The bottom-bar takeover: shadow the official stats pills (same id, lower
278
297
  // priority — the slot's lowest live entry renders) with this plugin's
279
298
  // always-on precision, token breakdown and live-speed readouts.