dsh-token-total 1.0.4 → 1.0.5

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,117 @@
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
+ /* Gray provider label under a model name shown by several rows (同名模型). */
44
+ .modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
45
+ .number { font-variant-numeric:tabular-nums; }
46
+ .speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
47
+ .speedCell small { font-size:9px; color:#929aaa; }
48
+ .good { color:#21a17b; }
49
+ .warn { color:#d69a21; }
50
+ .bad { color:#e06571; }
51
+ .live { color:#169bb3; }
52
+ .muted { color:#939aaa; }
53
+ .empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
54
+ .action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
55
+ .action:hover { color:#279cb6; }
56
+ .action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
57
+ .turnList { padding:0 14px 14px; }
58
+ .turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
59
+ .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; }
60
+ .chevron { color:#99a1af; transition:transform 0.2s; }
61
+ .rotated { transform:rotate(90deg); }
62
+ .turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }
63
+ /* Turn topic is the group title now (the page header owns the session name),
64
+ * so it carries the primary text color and truncates before the metrics. */
65
+ .turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
66
+ /* A subagent's turns look like this session's turns apart from the agent tag;
67
+ * the tag keeps the full session name in its tooltip. */
68
+ .turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }
69
+ .turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
70
+ .turnSummary > span:last-child { margin-left:8px; }
71
+ .turnBody { border-top:1px solid #eef0f4; }
72
+ .turnBody[hidden] { display:none; }
73
+ /* A request row is one line: time, what answered, and its metrics pinned to the
74
+ * row end. The metrics never wrap; they truncate with a tooltip before the head
75
+ * gives up its model name. */
76
+ .turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }
77
+ .turnRequest:first-child { border-top:0; }
78
+ .turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
79
+ .requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
80
+ .statusDot { width:7px; height:7px; border-radius:50%; background:currentColor; flex-shrink:0; }
81
+ .routeDot { width:6px; height:6px; border-radius:50%; background:#21a17b; flex-shrink:0; }
82
+ .whale { color:#4d6bfe; flex-shrink:0; }
83
+ .recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
84
+ .routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
85
+ .effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
86
+ .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; }
87
+ .note { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }
88
+ .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; }
89
+ @container (max-width:1050px) {
90
+ .mid { grid-template-columns:minmax(0,1fr); }
91
+ .statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
92
+ .statValue { font-size:23px; }
93
+ .table { font-size:12px; }
94
+ .childName { width:34%; }
95
+ }
96
+ @container (max-width:700px) {
97
+ .page { padding:18px 14px 24px; }
98
+ .stat { padding:12px 12px 11px; }
99
+ .statValue { font-size:20px; }
100
+ .table { font-size:10px; }
101
+ .table th,.table td { padding:10px 3px; }
102
+ .table th:first-child,.table td:first-child { padding-left:10px; }
103
+ .table th:last-child,.table td:last-child { padding-right:10px; }
104
+ .childName { width:25%; }
105
+ .childRequests { width:11%; }
106
+ .modelName { width:25%; }
107
+ .modelRequests { width:11%; }
108
+ .modelHit { width:17%; }
109
+ .turnHead { gap:8px; padding:11px 10px; }
110
+ .turnSummary { grid-column:2; font-size:10px; }
111
+ .turnRequest { grid-template-columns:56px minmax(120px,1fr) auto; gap:8px; padding:10px; }
112
+ .turnRequestHead { gap:6px; }
113
+ .routeText { display:none; }
114
+ }
115
+ @media (prefers-reduced-motion:reduce) {
116
+ .chevron { transition:none; }
117
+ }
@@ -0,0 +1,217 @@
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 css from './SessionUsageView.module.css'
9
+
10
+ type T = (key: SessionUsageKey) => string
11
+ type Row = SessionUsageDetail['records'][number]
12
+ export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
13
+
14
+ const seconds = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : '—'
15
+ // Request-log style metrics follow the reference log: seconds without trailing
16
+ // ".0", short lowercase tokens (80.0k / 1.5M), integer percentages.
17
+ const secText = (ms: number | null): string => ms !== null && Number.isFinite(ms) ? String(Math.round(ms / 100) / 10) : '—'
18
+ const tokShort = (n: number): string => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
19
+ const pctInt = (rate: number | null): string => rate === null ? '—' : `${Math.round(rate)}%`
20
+ const speed = (n: number | null): string => n !== null && Number.isFinite(n) ? `${Math.round(n)} tok/s` : '—'
21
+ const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
22
+ const whale = 'M8 39.5C13.5 27.6 26.8 19.4 42.5 19.1c-1.6 2.4-2.3 5-2 7.9-5.6-1.7-11.7-.4-16.2 3.4 4.7 1.1 8.4 4.3 10.2 8.7 1.3-.4 2.7-1 4-1.9.4 2.1-.2 4.3-1.6 5.9-7.7.6-15.5-1.6-20.7-6.6-.7 1.9-.9 3.9-.6 5.9-3.1-1.2-5.6-3-7.6-2.9z'
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.ellipsis} title={row.model}>{name}</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}`}><svg className={css.whale} width="13" height="13" viewBox="0 0 64 64" aria-hidden="true"><path d={whale} fill="currentColor" /></svg>{record.model || '—'}</span>
191
+ <span className={css.routeDot} />
192
+ <span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
193
+ {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
194
+ {record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
195
+ </div>
196
+ {record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
197
+ </div>
198
+ })}
199
+ </div>
200
+ </article>
201
+ })}</div>{detail.records.length === 0 && <p className={css.empty}>{t('empty')}</p>}
202
+ </section>
203
+ }
204
+
205
+ /** Pure presentation export also used by offline visual regression fixtures. */
206
+ export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
207
+ 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></>
208
+ }
209
+
210
+ export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
211
+ const { detail, loading, error, retry } = useSessionDetail(sessionId)
212
+ return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
213
+ {loading && <p className={css.empty} role="status">{t('loading')}</p>}
214
+ {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
215
+ {detail && <SessionUsageContent detail={detail} t={t} />}
216
+ </div></div>
217
+ }
@@ -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.
@@ -0,0 +1,50 @@
1
+ /** Session Usage view translations, independent of the global dashboard. */
2
+ export const SESSION_USAGE_NS = 'sessionUsage'
3
+ export const usageZh = {
4
+ tab: '用量', requests: '请求数', tokens: '总 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
5
+ input: '入', output: '出', read: '读', write: '写',
6
+ requestsCount: '请求数量', inputFull: '输入', outputFull: '输出', readFull: '缓存读取', writeFull: '缓存写入',
7
+ cacheHit: '缓存命中', compaction: '压缩调用', reqTimes: '个请求',
8
+ children: '子代理', session: '会话', models: '按模型', model: '模型',
9
+ records: '请求记录', expand: '全部展开', collapse: '全部收起', loading: '正在读取会话用量…',
10
+ empty: '本会话还没有请求记录', noChildren: '暂无子代理', noModels: '暂无模型用量',
11
+ scope: '本会话及所有子代理 · 历史日志累计',
12
+ note: '统计包含本会话与全部后代的独立请求、重试和压缩调用,不重复计算继承历史。首 token 与速度仅使用已记录的时间信息,缺失时显示 —。',
13
+ failedLoad: '用量读取失败,已保留上次结果。', retry: '重新读取', completed: '已完成', running: '进行中', failed: '失败', interrupted: '已中断',
14
+ main: '主会话', turn: '轮', step: '步', started: '开始', duration: '耗时', firstDetail: '首 token',
15
+ provider: '供应商', status: '状态', exact: '完整 token 数',
16
+ answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
17
+ cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
18
+ }
19
+ export type SessionUsageKey = keyof typeof usageZh
20
+ export const usageEn: Record<SessionUsageKey, string> = {
21
+ tab: 'Usage', requests: 'Requests', tokens: 'Total tokens', hit: 'Cache hit rate', first: 'Avg. first token', speed: 'Token speed',
22
+ input: 'In', output: 'Out', read: 'Read', write: 'Write',
23
+ requestsCount: 'Requests', inputFull: 'Input', outputFull: 'Output', readFull: 'Cache read', writeFull: 'Cache write',
24
+ cacheHit: 'cache hits', compaction: 'Compaction', reqTimes: 'requests',
25
+ children: 'Subagents', session: 'Session', models: 'By model', model: 'Model',
26
+ records: 'Request log', expand: 'Expand all', collapse: 'Collapse all', loading: 'Reading session usage…',
27
+ empty: 'No requests in this session yet', noChildren: 'No subagents yet', noModels: 'No model usage yet',
28
+ scope: 'This session and all subagents · Historical log totals',
29
+ note: 'Includes independent requests, retries and compaction calls from this session and all descendants, without counting inherited history twice. Timing uses recorded timestamps only; missing values appear as —.',
30
+ failedLoad: 'Could not read usage. The last successful result is preserved.', retry: 'Retry', completed: 'Completed', running: 'Running', failed: 'Failed', interrupted: 'Interrupted',
31
+ main: 'Main session', turn: 'Turn', step: 'Step', started: 'Started', duration: 'Duration', firstDetail: 'First token',
32
+ provider: 'Provider', status: 'Status', exact: 'Exact token count',
33
+ answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
34
+ cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
35
+ }
36
+ export const usageZhTW: Record<SessionUsageKey, string> = {
37
+ ...usageZh,
38
+ tab: '用量', requests: '請求數', tokens: '總 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
39
+ input: '入', output: '出', read: '讀', write: '寫',
40
+ requestsCount: '請求數量', inputFull: '輸入', outputFull: '輸出', readFull: '快取讀取', writeFull: '快取寫入',
41
+ cacheHit: '快取命中', compaction: '壓縮呼叫', reqTimes: '個請求',
42
+ children: '子代理', session: '會話', models: '按模型', records: '請求記錄', expand: '全部展開', collapse: '全部收起',
43
+ loading: '正在讀取會話用量…', empty: '本會話還沒有請求記錄', noChildren: '暫無子代理', noModels: '暫無模型用量',
44
+ scope: '本會話及所有子代理 · 歷史日誌累計',
45
+ note: '統計包含本會話與全部後代的獨立請求、重試和壓縮呼叫,不重複計算繼承歷史。首 token 與速度僅使用已記錄的時間資訊,缺失時顯示 —。',
46
+ failedLoad: '用量讀取失敗,已保留上次結果。', retry: '重新讀取', completed: '已完成', running: '進行中', failed: '失敗', interrupted: '已中斷',
47
+ main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
48
+ answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
49
+ cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
50
+ }
@@ -0,0 +1,50 @@
1
+ /** Serialized polling for one visible Usage view, never leaking another
2
+ * session's answer. Abort and stop all timers when the tab unmounts. */
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import type { SessionUsageDetail } from '../wire.ts'
5
+ import { fetchSessionDetail } from './api.ts'
6
+
7
+ export const DETAIL_POLL_MS = 1_500
8
+
9
+ export function useSessionDetail(sessionId: string): {
10
+ detail: SessionUsageDetail | null
11
+ loading: boolean
12
+ error: boolean
13
+ retry: () => void
14
+ } {
15
+ const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean } | null>(null)
16
+ const refresh = useRef<(() => void) | null>(null)
17
+ useEffect(() => {
18
+ let active = true
19
+ let pending = false
20
+ let timer: ReturnType<typeof setTimeout> | undefined
21
+ let controller: AbortController | undefined
22
+ const request = (): void => {
23
+ if (!active || pending || !sessionId) return
24
+ if (timer !== undefined) clearTimeout(timer)
25
+ pending = true
26
+ controller = new AbortController()
27
+ void fetchSessionDetail(sessionId, controller.signal).then(detail => {
28
+ if (active) setReading({ sessionId, detail, error: false })
29
+ }).catch(() => {
30
+ if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true }))
31
+ }).finally(() => {
32
+ pending = false
33
+ if (active) timer = setTimeout(request, DETAIL_POLL_MS)
34
+ })
35
+ }
36
+ const wake = (): void => { if (document.visibilityState === 'visible') request() }
37
+ refresh.current = request
38
+ document.addEventListener('visibilitychange', wake)
39
+ request()
40
+ return () => {
41
+ active = false
42
+ refresh.current = null
43
+ controller?.abort()
44
+ if (timer !== undefined) clearTimeout(timer)
45
+ document.removeEventListener('visibilitychange', wake)
46
+ }
47
+ }, [sessionId])
48
+ const current = reading?.sessionId === sessionId ? reading : null
49
+ return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, retry: () => refresh.current?.() }
50
+ }