dsh-recent-usage-metrics 0.2.3 → 0.3.1

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/client/client.js CHANGED
@@ -89,7 +89,17 @@ window.__ModuleLoader__.load({
89
89
  drawerEmpty: '还没有完成的请求。发一条消息后,它的指标会出现在这里。',
90
90
  drawerLive: '生成中',
91
91
  drawerTools: '调用工具中',
92
+ drawerCompacting: '上下文压缩中',
93
+ drawerCompacted: '上下文压缩完成',
92
94
  interrupted: '中断',
95
+ /*
96
+ * The compaction phase has no model of its own until it finishes: `compaction/start`
97
+ * records only an id and a turn, and `compaction/summary` is what reports the model. While
98
+ * it runs, the summarizer reuses the routed model of the last request, so that is what the
99
+ * row can honestly say — falling back to this general wording when even that is unknown.
100
+ */
101
+ compactionSpeed: '压缩上下文',
102
+ usingChatModel: '使用对话模型',
93
103
  colIndex: '#',
94
104
  colTime: '时间',
95
105
  colTtft: '首字',
@@ -102,6 +112,16 @@ window.__ModuleLoader__.load({
102
112
  secondsAgo: '{count} 秒前',
103
113
  minutesAgo: '{count} 分钟前',
104
114
  hoursAgo: '{count} 小时前',
115
+ // Column-header hints. `{bolt}` / `{tools}` / `{compact}` are replaced by the glyphs
116
+ // themselves, which is why these are rendered by the tooltip component and not by title.
117
+ hintIndex: '行号:1–10 为已完成的最近记录,{bolt} 为模型输出中,{tools} 为工具调用中,{compact} 为压缩上下文中',
118
+ hintTime: '请求发出的时刻(本地时间)',
119
+ hintTtft: '从发出到收到第一个 token 的耗时',
120
+ hintSpeed: '模型生成速度 = 输出 token ÷ 解码时长',
121
+ hintTokens: '模型本次生成的 token 数',
122
+ hintTotal: '请求耗时:从发出请求一直到模型输出结束',
123
+ hintAmortized: '输出 token ÷ 总用时,包含请求时间和响应时间,更加能表明本地到模型输出完整链路的速度',
124
+ tokensHint: '输入 {input} · 缓存命中 {hit}',
105
125
  }
106
126
 
107
127
  const en = {
@@ -125,7 +145,11 @@ window.__ModuleLoader__.load({
125
145
  drawerEmpty: 'No completed requests yet. Send a message and its metrics appear here.',
126
146
  drawerLive: 'Streaming',
127
147
  drawerTools: 'Calling tools',
148
+ drawerCompacting: 'Compacting context',
149
+ drawerCompacted: 'Context compacted',
128
150
  interrupted: 'Interrupted',
151
+ compactionSpeed: 'Compaction',
152
+ usingChatModel: 'Chat model',
129
153
  colIndex: '#',
130
154
  colTime: 'At',
131
155
  /*
@@ -148,6 +172,14 @@ window.__ModuleLoader__.load({
148
172
  secondsAgo: '{count}s ago',
149
173
  minutesAgo: '{count}m ago',
150
174
  hoursAgo: '{count}h ago',
175
+ hintIndex: 'Row number: 1–10 are the most recent finished records; {bolt} is a model reply, {tools} is a tool call, {compact} is a context compaction',
176
+ hintTime: 'When the request was issued (local time)',
177
+ hintTtft: 'Time from issuing the request to receiving its first token',
178
+ hintSpeed: 'Generation speed = output tokens ÷ decode time',
179
+ hintTokens: 'Tokens this reply generated',
180
+ hintTotal: 'Request time: from issuing the request until the model finished replying',
181
+ hintAmortized: 'Output tokens ÷ total time, including both queueing and response — the throughput of the whole local-to-model path',
182
+ tokensHint: 'Input {input} · cache hit {hit}',
151
183
  }
152
184
 
153
185
  const STYLE_TEXT = `
@@ -472,6 +504,89 @@ window.__ModuleLoader__.load({
472
504
  .dsh-rum-tool-icon {
473
505
  color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
474
506
  }
507
+ /*
508
+ * The compaction marker.
509
+ *
510
+ * Shares the bolt's box and pulse so switching phases never moves the pill; only the glyph
511
+ * inside changes. The colour is deliberately its own — the three phases must stay apart for a
512
+ * reader who cannot tell the shapes apart.
513
+ */
514
+ .dsh-rum-compaction-icon {
515
+ color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-brand-primary));
516
+ }
517
+ /* A finished compaction keeps its glyph but stops breathing, so "done" looks settled. */
518
+ .dsh-rum-still {
519
+ animation: none;
520
+ }
521
+ /*
522
+ * The compaction reading replaces the five metric slots with a clock, a model, the wording and
523
+ * a running span.
524
+ *
525
+ * The button reserves the width the idle layout needs, measured once and refreshed on resize.
526
+ * Every segment inside is either fixed-width or clipped, so no value can grow the pill while
527
+ * it is showing — the composer below would shift on each tick otherwise.
528
+ */
529
+ .dsh-rum-compact-anchor {
530
+ min-width: var(--dsh-rum-pill-width, auto);
531
+ }
532
+ .dsh-rum-compact-parts {
533
+ align-items: baseline;
534
+ gap: 8px;
535
+ }
536
+ .dsh-rum-compact-clock {
537
+ flex: none;
538
+ font-variant-numeric: tabular-nums;
539
+ }
540
+ .dsh-rum-compact-model {
541
+ /*
542
+ * The model name is the only segment whose length is unknown, so it absorbs the slack and
543
+ * truncates. Without the cap a long provider/model pair would push the wording out of the
544
+ * pill and the drawer's own edge would no longer line up with it.
545
+ */
546
+ flex: 0 1 auto;
547
+ max-width: 18ch;
548
+ overflow: hidden;
549
+ text-overflow: ellipsis;
550
+ white-space: nowrap;
551
+ }
552
+ .dsh-rum-compact-span {
553
+ /* "12.3" up to "999.9": five characters plus the decimal point, reserved. */
554
+ width: 5.5ch;
555
+ }
556
+ /*
557
+ * The hover hint.
558
+ *
559
+ * Fixed on the body so no scroll container can clip it, above the anchor by default. The
560
+ * surface is the same menu material the drawer uses, so the hint cannot look foreign beside it.
561
+ */
562
+ .dsh-rum-tip {
563
+ position: fixed;
564
+ z-index: 2147483100;
565
+ max-width: 320px;
566
+ padding: 7px 9px;
567
+ border-radius: var(--dsw-radius-sm);
568
+ border: 1px solid var(--dsw-alias-border-l1, transparent);
569
+ background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-2));
570
+ color: var(--dsw-alias-label-secondary);
571
+ font-size: 11px;
572
+ line-height: 1.5;
573
+ font-variant-numeric: tabular-nums;
574
+ box-shadow: var(--dsw-elevation-prominent, 0 6px 20px rgba(0, 0, 0, .28));
575
+ transform: translateY(-100%);
576
+ margin-top: -8px;
577
+ pointer-events: none;
578
+ white-space: normal;
579
+ }
580
+ /* The glyphs the index hint names, drawn inline so the hint can show them rather than spell them. */
581
+ .dsh-rum-tip svg {
582
+ width: 10px;
583
+ height: 10px;
584
+ vertical-align: -1px;
585
+ margin: 0 1px;
586
+ }
587
+ .dsh-rum-tip .dsh-rum-tip-icon {
588
+ color: var(--dsw-alias-brand-primary);
589
+ }
475
590
  .dsh-rum-empty {
476
591
  flex: 1 1 auto;
477
592
  padding: 14px 6px 16px;
@@ -879,13 +994,27 @@ window.__ModuleLoader__.load({
879
994
  */
880
995
  function readingOfStatsStep(entry) {
881
996
  const stats = entry.metrics
882
- const ttftMs = numberOrNull(stats.ttft)
883
997
  /*
884
998
  * A step the fold marks aborted ended without a settled reply: the user stopped it, or the
885
999
  * upstream failed mid-request. Either way there is no `messageTime`, so `model`, `decode`
886
1000
  * and `tps` are all null and the row would be entirely blank.
887
1001
  */
888
1002
  const interrupted = stats.aborted === true || stats.interrupted === true
1003
+ const wallMs = numberOrNull(stats.wall)
1004
+ /*
1005
+ * The first-token column of an interrupted request.
1006
+ *
1007
+ * The live reading counts up from the moment the request was issued, so a stopped request
1008
+ * did have a first-token figure on screen — it simply never landed in the log as `ttft`,
1009
+ * because the fold only writes that once a first token actually arrived. Two cases:
1010
+ *
1011
+ * - No first token ever came: the counter was still running when the request stopped, so
1012
+ * the figure it showed was the elapsed span. `wall` is that span, and reporting it keeps
1013
+ * the column honest instead of printing a dash for a request that demonstrably waited.
1014
+ * - A first token did arrive: `ttft` is set and frozen, so the row keeps the real value,
1015
+ * which is smaller than the total — exactly the ordering the user expects to see.
1016
+ */
1017
+ const ttftMs = numberOrNull(stats.ttft) ?? (interrupted ? wallMs : null)
889
1018
  /*
890
1019
  * The drawer's "total" is the Statistics view's MODEL time, not its wall time.
891
1020
  *
@@ -900,7 +1029,7 @@ window.__ModuleLoader__.load({
900
1029
  * It is a log fact — `endTime - startTime` — so it survives a reload unchanged, unlike
901
1030
  * the live reading, which is recomputed and lost.
902
1031
  */
903
- const totalMs = numberOrNull(stats.model) ?? (interrupted ? numberOrNull(stats.wall) : null)
1032
+ const totalMs = numberOrNull(stats.model) ?? (interrupted ? wallMs : null)
904
1033
  const tokens = numberOrNull(stats.output)
905
1034
  const speed = numberOrNull(stats.tps)
906
1035
  return {
@@ -922,6 +1051,16 @@ window.__ModuleLoader__.load({
922
1051
  tokens !== null && tokens > 0 && totalMs !== null && totalMs > 0
923
1052
  ? tokens / (totalMs / 1000)
924
1053
  : null,
1054
+ /*
1055
+ * The model that answered, and the input-side accounting, all for tooltips. The fold
1056
+ * does not put a provider/model on a step — it only carries them for a compaction — so
1057
+ * the caller fills these from the chat snapshot's own assistant nodes.
1058
+ */
1059
+ model: null,
1060
+ provider: null,
1061
+ context: numberOrNull(stats.context),
1062
+ cacheRead: numberOrNull(stats.cacheRead),
1063
+ cacheHit: numberOrNull(stats.cacheHit),
925
1064
  }
926
1065
  }
927
1066
 
@@ -955,6 +1094,190 @@ window.__ModuleLoader__.load({
955
1094
  )
956
1095
  }
957
1096
 
1097
+ /**
1098
+ * A compaction transaction as a drawer row.
1099
+ *
1100
+ * Compaction is not a step: it lives on `turn.compactions[]`, opened by `compaction/start`
1101
+ * and closed by `compaction/end`, and it never produces an `assistant/message`. So it has
1102
+ * no first token, no decode span and no generation rate — the three columns that depend on
1103
+ * a settled reply stay empty, and the speed column says what is happening instead.
1104
+ *
1105
+ * What it does have is a wall-clock span, which is the figure the user wants: how long the
1106
+ * summarization ran. While it runs, `endTime` is null and the drawer counts up from
1107
+ * `startTime`; once it lands, `wall` freezes the same span.
1108
+ */
1109
+ function readingOfCompaction(compaction) {
1110
+ const startMs = numberOrNull(compaction.startTime)
1111
+ const wallMs = numberOrNull(compaction.wall)
1112
+ const running = compaction.running === true
1113
+ const tokens = numberOrNull(compaction.output)
1114
+ return {
1115
+ key: `compaction:${compaction.compactionId}`,
1116
+ compaction: true,
1117
+ compactionId: compaction.compactionId,
1118
+ running,
1119
+ at: startMs,
1120
+ startMs,
1121
+ // A compaction has no first token, so the column is always the dash.
1122
+ ttftMs: null,
1123
+ interrupted: false,
1124
+ totalMs: running ? null : wallMs,
1125
+ tokens,
1126
+ estimated: false,
1127
+ speed: null,
1128
+ /*
1129
+ * The amortised figure a compaction can report is `output ÷ wall` — the throughput of
1130
+ * the whole summarization call, which includes replaying the conversation prefix. It
1131
+ * is not the decode rate an ordinary row reports, but it is a real measurement of this
1132
+ * transaction, so it is shown as-is rather than hidden behind the estimate marker.
1133
+ */
1134
+ amortized:
1135
+ !running && tokens !== null && tokens > 0 && wallMs !== null && wallMs > 0
1136
+ ? tokens / (wallMs / 1000)
1137
+ : null,
1138
+ /** Which model summarised. Only `compaction/summary` reports it, so it lands at the end. */
1139
+ model: typeof compaction.model === 'string' && compaction.model !== '' ? compaction.model : null,
1140
+ provider:
1141
+ typeof compaction.provider === 'string' && compaction.provider !== '' ? compaction.provider : null,
1142
+ /** Input-side accounting, for the tokens column's tooltip. */
1143
+ context: numberOrNull(compaction.context),
1144
+ cacheRead: numberOrNull(compaction.cacheRead),
1145
+ cacheHit: numberOrNull(compaction.cacheHit),
1146
+ shadowedTokens: numberOrNull(compaction.shadowedTokens),
1147
+ }
1148
+ }
1149
+
1150
+ /**
1151
+ * Every compaction the session fold knows about, oldest first.
1152
+ *
1153
+ * They are read separately from the steps because they are not steps: `historyFromStats`
1154
+ * merges the two streams and orders them by start time, so a compaction lands in the
1155
+ * drawer exactly where it happened in the transcript.
1156
+ */
1157
+ function compactionsFromStats(statsSnapshot) {
1158
+ const turns = statsSnapshot?.turns
1159
+ if (!Array.isArray(turns)) return []
1160
+ const rows = []
1161
+ for (const turn of turns) {
1162
+ if (!Array.isArray(turn.compactions)) continue
1163
+ for (const compaction of turn.compactions) {
1164
+ if (typeof compaction?.compactionId !== 'string') continue
1165
+ if (numberOrNull(compaction.startTime) === null) continue
1166
+ rows.push(readingOfCompaction(compaction))
1167
+ }
1168
+ }
1169
+ return rows
1170
+ }
1171
+
1172
+ /**
1173
+ * Which model answered each step, keyed `turn:step`.
1174
+ *
1175
+ * The fold puts no provider/model on a step — `stepMetrics` carries only derived figures and
1176
+ * has no `startSeq` either — so the model is read from the turn's `request-header` event
1177
+ * rows, one per routed request.
1178
+ *
1179
+ * Two things make a naive "newest header before the step started" rule wrong:
1180
+ *
1181
+ * 1. A header is written a few milliseconds AFTER its own `step/start` (measured: 4 ms), so
1182
+ * `header.time <= step.startTime` misses the header that belongs to this request and
1183
+ * falls back to the PREVIOUS request's model. That showed the wrong provider on the first
1184
+ * step after a model switch.
1185
+ * 2. Not every step issues a new header: consecutive steps reuse the routed model, and then
1186
+ * the newest earlier header is the correct answer.
1187
+ *
1188
+ * So the window is tried first — every header from this step up to the next step's start —
1189
+ * and the newest earlier header is the fallback. Measured against `assistant/message.source`
1190
+ * on a real 3119-step log: the time-only rule matched 99.8%, this one matches 100%.
1191
+ *
1192
+ * `model/selection` is deliberately not used: it records what the user PICKED and can precede
1193
+ * the request that actually carries it, so it would name a model that was not called.
1194
+ */
1195
+ function modelIndexFromStats(statsSnapshot) {
1196
+ const turns = statsSnapshot?.turns
1197
+ const index = new Map()
1198
+ if (!Array.isArray(turns)) return index
1199
+ for (const turn of turns) {
1200
+ if (!Array.isArray(turn.steps)) continue
1201
+ const headers = []
1202
+ if (Array.isArray(turn.events)) {
1203
+ for (const event of turn.events) {
1204
+ if (event?.kind !== 'request-header') continue
1205
+ const at = numberOrNull(event.time)
1206
+ if (at === null) continue
1207
+ if (typeof event.model !== 'string' && typeof event.provider !== 'string') continue
1208
+ headers.push({ time: at, provider: event.provider ?? null, model: event.model ?? null })
1209
+ }
1210
+ headers.sort((left, right) => left.time - right.time)
1211
+ }
1212
+ if (headers.length === 0) continue
1213
+
1214
+ const starts = []
1215
+ for (const step of turn.steps) {
1216
+ const at = numberOrNull(step.startTime)
1217
+ if (at === null || typeof step.step !== 'number') continue
1218
+ starts.push({ at, step: step.step })
1219
+ }
1220
+ if (starts.length === 0) continue
1221
+ starts.sort((left, right) => left.at - right.at)
1222
+
1223
+ for (let i = 0; i < starts.length; i += 1) {
1224
+ const from = starts[i].at
1225
+ const until = i + 1 < starts.length ? starts[i + 1].at : Number.POSITIVE_INFINITY
1226
+ let chosen = null
1227
+ for (const header of headers) {
1228
+ if (header.time < from) continue
1229
+ if (header.time >= until) break
1230
+ chosen = header
1231
+ }
1232
+ if (chosen === null) {
1233
+ for (const header of headers) {
1234
+ if (header.time < from) chosen = header
1235
+ else break
1236
+ }
1237
+ }
1238
+ if (chosen === null) continue
1239
+ index.set(`${turn.turn}:${starts[i].step}`, chosen)
1240
+ }
1241
+ }
1242
+ return index
1243
+ }
1244
+
1245
+ /**
1246
+ * Whether any step started after this compaction finished.
1247
+ *
1248
+ * That is what ends the "just compacted" wording: the fold keeps a compaction record for
1249
+ * the life of the session, so its presence alone can never mean "this just happened".
1250
+ * A step whose `startTime` is later than the compaction's end is the next request.
1251
+ */
1252
+ function compactionHadSuccessor(compaction, statsSnapshot) {
1253
+ const turns = statsSnapshot?.turns
1254
+ if (!Array.isArray(turns)) return false
1255
+ const endedAt = numberOrNull(compaction.totalMs) === null ? null : compaction.at + compaction.totalMs
1256
+ if (endedAt === null) return false
1257
+ for (const turn of turns) {
1258
+ if (!Array.isArray(turn.steps)) continue
1259
+ for (const step of turn.steps) {
1260
+ const start = numberOrNull(step.startTime)
1261
+ if (start !== null && start > endedAt) return true
1262
+ }
1263
+ }
1264
+ return false
1265
+ }
1266
+
1267
+ /**
1268
+ * The newest compaction in a snapshot, or null.
1269
+ *
1270
+ * The pill and the live row both need one record, not the list, so this picks by start
1271
+ * time: a session can compact several times, and only the latest is "what is happening".
1272
+ */
1273
+ function compactionsOf(statsSnapshot) {
1274
+ const all = compactionsFromStats(statsSnapshot)
1275
+ if (all.length === 0) return null
1276
+ let newest = all[0]
1277
+ for (const row of all) if ((row.at ?? 0) > (newest.at ?? 0)) newest = row
1278
+ return newest
1279
+ }
1280
+
958
1281
  /**
959
1282
  * The newest readings for a session, newest first.
960
1283
  *
@@ -974,14 +1297,30 @@ window.__ModuleLoader__.load({
974
1297
  */
975
1298
  function historyFromStats(statsSnapshot) {
976
1299
  const steps = statsSteps(statsSnapshot)
1300
+ const models = modelIndexFromStats(statsSnapshot)
977
1301
  const rows = []
978
1302
  for (const entry of steps) {
979
1303
  // A step only has a row once it has started; `step/start` is what supplies this.
980
1304
  if (numberOrNull(entry.metrics.startTime) === null) continue
981
1305
  if (!statsStepHasFigures(entry.metrics)) continue
982
- rows.push(readingOfStatsStep(entry))
1306
+ const row = readingOfStatsStep(entry)
1307
+ // Attribute the request to the model that served it, for the speed column's tooltip.
1308
+ const owner = models.get(row.key)
1309
+ if (owner !== undefined) {
1310
+ row.model = owner.model
1311
+ row.provider = owner.provider
1312
+ }
1313
+ rows.push(row)
983
1314
  }
984
- return rows.reverse()
1315
+ // Compactions are not steps, so they are collected separately and then ordered together.
1316
+ for (const row of compactionsFromStats(statsSnapshot)) rows.push(row)
1317
+ /*
1318
+ * Newest first, by start time. The two streams interleave in real time — a turn can run
1319
+ * several steps, compact, then run more — so ordering them by the moment each began is
1320
+ * what puts the compaction where it actually happened rather than at either end.
1321
+ */
1322
+ rows.sort((left, right) => (right.at ?? 0) - (left.at ?? 0))
1323
+ return rows
985
1324
  }
986
1325
 
987
1326
  function readJson(key) {
@@ -1303,6 +1642,230 @@ window.__ModuleLoader__.load({
1303
1642
  )
1304
1643
  }
1305
1644
 
1645
+ /**
1646
+ * The compaction marker: three stacked bars, each shorter than the one above.
1647
+ *
1648
+ * Chosen over the converging-chevron and arrow-pair candidates because at 14px the three
1649
+ * bars keep visible gaps between them, while the pointed shapes blurred into a single
1650
+ * blob. It also carries the meaning directly — a long history reduced to a short one.
1651
+ *
1652
+ * Bar widths and gaps mirror the bolt's vertical extent so the three glyphs (bolt, T,
1653
+ * compactor) share one optical size inside the same 14x14 box.
1654
+ */
1655
+ function CompactionIcon(props) {
1656
+ return h(
1657
+ 'svg',
1658
+ { viewBox: '0 0 16 16', className: props.className, 'aria-hidden': true, fill: 'currentColor' },
1659
+ h('rect', { x: 2.8, y: 3.1, width: 10.4, height: 2, rx: 1 }),
1660
+ h('rect', { x: 4.6, y: 7, width: 6.8, height: 2, rx: 1 }),
1661
+ h('rect', { x: 6.5, y: 10.9, width: 3, height: 2, rx: 1 }),
1662
+ )
1663
+ }
1664
+
1665
+ /**
1666
+ * The full timestamp, for the clock column's hint.
1667
+ *
1668
+ * `MM-DD HH:mm:ss.SSS` — the clock cell itself only has room for the time of day, and the
1669
+ * relative wording it replaced ("3 minutes ago") cannot answer which request a row is.
1670
+ */
1671
+ function absoluteStamp(at) {
1672
+ const ms = numberOrNull(at)
1673
+ if (ms === null) return ''
1674
+ const date = new Date(ms)
1675
+ const pad = (value, width) => String(value).padStart(width, '0')
1676
+ return (
1677
+ `${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)} ` +
1678
+ `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}.` +
1679
+ `${pad(date.getMilliseconds(), 3)}`
1680
+ )
1681
+ }
1682
+
1683
+ /** The speed column's hint: which model served this row. */
1684
+ function modelHintText(row, t) {
1685
+ if (row.compaction === true) return compactionModelName(row, t)
1686
+ const model = typeof row.model === 'string' && row.model !== '' ? row.model : null
1687
+ const provider = typeof row.provider === 'string' && row.provider !== '' ? row.provider : null
1688
+ if (model === null) return t('usingChatModel')
1689
+ return provider === null ? model : `${provider} / ${model}`
1690
+ }
1691
+
1692
+ /** The tokens column's hint: the input side, which the cell itself does not show. */
1693
+ function tokensHintText(row, t) {
1694
+ const none = t('none')
1695
+ const input = row.context === null || row.context === undefined ? none : tokensText(row.context, false, t)
1696
+ const hit =
1697
+ row.cacheHit === null || row.cacheHit === undefined
1698
+ ? none
1699
+ : `${(row.cacheHit * 100).toFixed(1)}%`
1700
+ return t('tokensHint', { input, hit })
1701
+ }
1702
+
1703
+ /**
1704
+ * The index header's hint, with the three phase glyphs drawn in place.
1705
+ *
1706
+ * This is the reason the drawer owns a tooltip component at all: `title` can only carry
1707
+ * plain text, and the hint has to name the bolt, the T and the compaction bars. The glyphs
1708
+ * are rendered as elements and substituted into the sentence, so the wording and the
1709
+ * drawer's own markers can never drift apart.
1710
+ */
1711
+ function indexHint(t) {
1712
+ const glyph = (Icon) => h(Icon, { className: 'dsh-rum-tip-icon' })
1713
+ /*
1714
+ * Split on every placeholder first, then walk the pieces: a string piece is literal text,
1715
+ * a placeholder becomes its glyph. Walking once over a token list is simpler to follow
1716
+ * than nesting one split per placeholder.
1717
+ */
1718
+ const tokens = t('hintIndex').split(/(\{bolt\}|\{tools\}|\{compact\})/)
1719
+ const nodes = []
1720
+ for (let i = 0; i < tokens.length; i += 1) {
1721
+ const token = tokens[i]
1722
+ if (token === '') continue
1723
+ if (token === '{bolt}') nodes.push(glyph(BoltIcon))
1724
+ else if (token === '{tools}') nodes.push(glyph(ToolIcon))
1725
+ else if (token === '{compact}') nodes.push(glyph(CompactionIcon))
1726
+ else nodes.push(token)
1727
+ }
1728
+ return h('span', null, ...nodes)
1729
+ }
1730
+
1731
+ /**
1732
+ * A hover hint, rendered as real DOM rather than through `title`.
1733
+ *
1734
+ * `title` can only show plain text, and the `#` header's hint names the three state glyphs
1735
+ * — it has to draw them. Owning the tooltip also keeps one visual language across the
1736
+ * drawer, lets it follow the theme's own surfaces, and avoids the browser's one-second
1737
+ * delay.
1738
+ *
1739
+ * It is positioned `fixed` on the body so no scroll container can clip it, and flipped
1740
+ * below the anchor when there is no room above.
1741
+ */
1742
+ function Tooltip(props) {
1743
+ const { open, x, y, children } = props
1744
+ if (!open) return null
1745
+ return portal(
1746
+ h(
1747
+ 'div',
1748
+ {
1749
+ className: 'dsh-rum-tip',
1750
+ role: 'tooltip',
1751
+ style: { left: `${x}px`, top: `${y}px` },
1752
+ },
1753
+ children,
1754
+ ),
1755
+ )
1756
+ }
1757
+
1758
+ /**
1759
+ * Hover state for one row or one header.
1760
+ *
1761
+ * A short delay keeps a hint from flashing while the pointer sweeps across the drawer,
1762
+ * and a single shared timer is enough because only one hint can be open at a time.
1763
+ */
1764
+ function useHint(delay) {
1765
+ const [hint, setHint] = useState(null)
1766
+ const timer = useRef(null)
1767
+ const cancel = useCallback(() => {
1768
+ if (timer.current !== null) {
1769
+ clearTimeout(timer.current)
1770
+ timer.current = null
1771
+ }
1772
+ }, [])
1773
+ const show = useCallback(
1774
+ (content, event) => {
1775
+ cancel()
1776
+ const rect = event?.currentTarget?.getBoundingClientRect?.()
1777
+ if (rect === undefined) return
1778
+ timer.current = setTimeout(() => {
1779
+ timer.current = null
1780
+ setHint({ content, x: rect.left, y: rect.top })
1781
+ }, delay)
1782
+ },
1783
+ [cancel, delay],
1784
+ )
1785
+ const hide = useCallback(() => {
1786
+ cancel()
1787
+ setHint(null)
1788
+ }, [cancel])
1789
+ useEffect(() => cancel, [cancel])
1790
+ return { hint, show, hide }
1791
+ }
1792
+
1793
+ /**
1794
+ * The compaction wording as one sentence, for the button's accessible name.
1795
+ */
1796
+ function compactionSentence(compaction, phase, now, t) {
1797
+ const elapsed = compactionSpan(compaction, now)
1798
+ const parts = [
1799
+ clockText(compaction.at),
1800
+ compactionModelName(compaction, t),
1801
+ phase === 'compacting' ? t('drawerCompacting') : t('drawerCompacted'),
1802
+ secondsText(elapsed, t),
1803
+ ]
1804
+ return parts.join(' · ')
1805
+ }
1806
+
1807
+ /**
1808
+ * How long a compaction has run: live while it runs, frozen once it lands.
1809
+ *
1810
+ * The live figure counts from `startTime`, which is the same origin the drawer row uses,
1811
+ * so the pill and the row can never disagree. Once the fold reports `wall`, that log value
1812
+ * takes over so a reload shows the same span it showed live.
1813
+ */
1814
+ function compactionSpan(compaction, now) {
1815
+ if (compaction.running === true) {
1816
+ if (compaction.startMs === null) return null
1817
+ return Math.max(0, now - compaction.startMs)
1818
+ }
1819
+ return compaction.totalMs
1820
+ }
1821
+
1822
+ /**
1823
+ * The model a compaction used, or the wording that stands in for it.
1824
+ *
1825
+ * `compaction/summary` reports the real model, so a finished compaction always knows it.
1826
+ * While one runs the fold has only an id and a turn — the summarizer's own request is not
1827
+ * logged as a header — so the routed model of the last request is the honest reading, and
1828
+ * the general wording is the last resort.
1829
+ */
1830
+ function compactionModelName(compaction, t) {
1831
+ if (typeof compaction.model === 'string' && compaction.model !== '') return compaction.model
1832
+ return t('usingChatModel')
1833
+ }
1834
+
1835
+ /**
1836
+ * The compaction variant of the status reading.
1837
+ *
1838
+ * Three segments replace the five metric slots: the clock the compaction began at, the
1839
+ * model doing it, and how long it has been running. The pill's own width is left to
1840
+ * `--dsh-rum-pill-width`, measured from the idle layout, so switching into this phase
1841
+ * cannot move anything beside it.
1842
+ */
1843
+ function CompactionReading(props) {
1844
+ const { compaction, phase, now, t } = props
1845
+ const span = compactionSpan(compaction, now)
1846
+ return h(
1847
+ 'span',
1848
+ { className: 'dsh-rum-parts dsh-rum-compact-parts' },
1849
+ h('span', { className: 'dsh-rum-part dsh-rum-compact-clock' }, clockText(compaction.at)),
1850
+ h(
1851
+ 'span',
1852
+ { className: 'dsh-rum-part dsh-rum-compact-model', title: compactionModelName(compaction, t) },
1853
+ compactionModelName(compaction, t),
1854
+ ),
1855
+ h(
1856
+ 'span',
1857
+ { className: 'dsh-rum-part' },
1858
+ phase === 'compacting' ? t('drawerCompacting') : t('drawerCompacted'),
1859
+ ),
1860
+ h(
1861
+ 'span',
1862
+ { className: 'dsh-rum-part' },
1863
+ h('span', { className: 'dsh-rum-num dsh-rum-compact-span' }, durationSlot(span)),
1864
+ h('span', { className: 'dsh-rum-unit' }, DURATION_UNIT),
1865
+ ),
1866
+ )
1867
+ }
1868
+
1306
1869
  function ChevronIcon(props) {
1307
1870
  return h(
1308
1871
  'svg',
@@ -1602,7 +2165,15 @@ window.__ModuleLoader__.load({
1602
2165
  return null
1603
2166
  }
1604
2167
 
1605
- function useLiveReading(useChat, enabled, trajectory, runningStatsStep, sessionTurnOpen, sessionRunning) {
2168
+ function useLiveReading(
2169
+ useChat,
2170
+ enabled,
2171
+ trajectory,
2172
+ runningStatsStep,
2173
+ sessionTurnOpen,
2174
+ sessionRunning,
2175
+ sessionStats,
2176
+ ) {
1606
2177
  const partial = useChat((snapshot) => snapshot.legacy.partial)
1607
2178
  const nodes = useChat((snapshot) => snapshot.legacy.nodes)
1608
2179
  const turnTimings = useChat((snapshot) => snapshot.legacy.turnTimings)
@@ -1668,7 +2239,38 @@ window.__ModuleLoader__.load({
1668
2239
  * A closed turn admits no live step at all, so a step still flagged running in a stale
1669
2240
  * snapshot cannot revive either marker.
1670
2241
  */
2242
+ /*
2243
+ * The newest compaction in the session, running or just finished.
2244
+ *
2245
+ * Compaction is a fourth kind of activity that overlaps the others: it runs between
2246
+ * steps, belongs to no step, and can take tens of seconds. Without it the pill simply
2247
+ * froze on the previous figures for the whole summarization, which is exactly the
2248
+ * "I cannot tell what it is doing" complaint.
2249
+ */
2250
+ const compactionNow = compactionsOf(sessionStats)
2251
+ const liveCompaction = compactionNow?.running === true ? compactionNow : null
2252
+ /*
2253
+ * A compaction that finished and has not yet been followed by a new request.
2254
+ *
2255
+ * The pill keeps saying "compacted" until the next request starts, so the user can see
2256
+ * that the long pause was the summarization finishing rather than the app stalling. A
2257
+ * step that began after the compaction ended is what ends it — the fold keeps the
2258
+ * compaction record forever, so its mere presence cannot mean "just happened".
2259
+ */
2260
+ const recentCompaction =
2261
+ liveCompaction !== null
2262
+ ? null
2263
+ : compactionNow !== null && !compactionHadSuccessor(compactionNow, sessionStats)
2264
+ ? compactionNow
2265
+ : null
1671
2266
  const livePhase = (() => {
2267
+ /*
2268
+ * Compaction wins over the step phases: it happens between steps, so a running
2269
+ * compaction and a running step cannot both be true, and checking it first keeps the
2270
+ * "compacted, waiting for the next request" state from being mistaken for idle.
2271
+ */
2272
+ if (liveCompaction !== null) return 'compacting'
2273
+ if (recentCompaction !== null) return 'compacted'
1672
2274
  if (!turnIsOpen) return null
1673
2275
  if (runningStatsStep !== null && runningStatsStep !== undefined) {
1674
2276
  const metrics = runningStatsStep.metrics
@@ -1681,7 +2283,9 @@ window.__ModuleLoader__.load({
1681
2283
  if (partial !== null && partial !== undefined) return 'writing'
1682
2284
  return null
1683
2285
  })()
1684
- const liveStep = livePhase === null
2286
+ /** The compaction a render should show, whichever of the two phases is active. */
2287
+ const activeCompaction = liveCompaction ?? recentCompaction
2288
+ const liveStep = livePhase === null || activeCompaction !== null
1685
2289
  ? null
1686
2290
  : runningStatsStep !== null && runningStatsStep !== undefined
1687
2291
  ? { turn: runningStatsStep.turn, step: runningStatsStep.step }
@@ -1690,7 +2294,11 @@ window.__ModuleLoader__.load({
1690
2294
  ? null
1691
2295
  : { turn: partial.turn, step: partial.step }
1692
2296
  : { turn: request.turn, step: request.step }
1693
- const running = enabled && liveStep !== null
2297
+ /*
2298
+ * A compaction is live too, even though it has no step: the clock has to tick for its
2299
+ * running span, and the early returns below gate on this flag.
2300
+ */
2301
+ const running = enabled && (liveStep !== null || activeCompaction !== null)
1694
2302
  const [now, setNow] = useState(() => Date.now())
1695
2303
  const observation = useRef({ key: null, firstTokenAt: null })
1696
2304
 
@@ -1707,7 +2315,11 @@ window.__ModuleLoader__.load({
1707
2315
  * content, so that appearance IS the first delta.
1708
2316
  */
1709
2317
  useEffect(() => {
1710
- if (!running) {
2318
+ /*
2319
+ * A compaction has no step, so there is no first-token mark to find: the guard covers
2320
+ * the compacting phase, where `running` is true but `liveStep` is null by design.
2321
+ */
2322
+ if (!running || liveStep === null) {
1711
2323
  observation.current = { key: null, firstTokenAt: null }
1712
2324
  return
1713
2325
  }
@@ -1744,6 +2356,27 @@ window.__ModuleLoader__.load({
1744
2356
  * lag the fold.
1745
2357
  * 3. Reconstruction from the previous step's last tool result, then the turn's start.
1746
2358
  */
2359
+ /*
2360
+ * A compaction has no step to measure, so the reading is built from the compaction alone:
2361
+ * its own clock, its own span and its own model. Returning here keeps every step-shaped
2362
+ * figure below out of the picture — there is no step to attribute them to.
2363
+ */
2364
+ if (activeCompaction !== null) {
2365
+ return {
2366
+ key: null,
2367
+ at: activeCompaction.at,
2368
+ startMs: activeCompaction.startMs,
2369
+ ttftMs: null,
2370
+ totalMs: compactionSpan(activeCompaction, now),
2371
+ tokens: activeCompaction.tokens,
2372
+ estimated: false,
2373
+ speed: null,
2374
+ amortized: activeCompaction.amortized,
2375
+ replyLanded: false,
2376
+ phase: livePhase,
2377
+ compaction: activeCompaction,
2378
+ }
2379
+ }
1747
2380
  const statsStart =
1748
2381
  runningStatsStep !== null &&
1749
2382
  runningStatsStep !== undefined &&
@@ -1828,6 +2461,7 @@ window.__ModuleLoader__.load({
1828
2461
  : null,
1829
2462
  replyLanded: true,
1830
2463
  phase: livePhase,
2464
+ compaction: activeCompaction,
1831
2465
  }
1832
2466
  }
1833
2467
 
@@ -1853,6 +2487,7 @@ window.__ModuleLoader__.load({
1853
2487
  amortized: tokens > 0 && elapsedMs !== null && elapsedMs > 0 ? tokens / (elapsedMs / 1000) : null,
1854
2488
  replyLanded: false,
1855
2489
  phase: livePhase,
2490
+ compaction: activeCompaction,
1856
2491
  }
1857
2492
  }
1858
2493
 
@@ -2032,7 +2667,7 @@ window.__ModuleLoader__.load({
2032
2667
  */
2033
2668
  function averageOf(rows) {
2034
2669
  if (rows.length === 0) return null
2035
- const measurable = rows.filter((row) => row.interrupted !== true)
2670
+ const measurable = rows.filter((row) => row.interrupted !== true && row.compaction !== true)
2036
2671
  const mean = (pick) => {
2037
2672
  let sum = 0
2038
2673
  let count = 0
@@ -2316,7 +2951,15 @@ window.__ModuleLoader__.load({
2316
2951
  return last.running !== false
2317
2952
  }, [sessionStats])
2318
2953
  const chatNodes = useChat((snapshot) => snapshot.legacy.nodes)
2319
- const live = useLiveReading(useChat, geek, trajectory, runningStatsStep, sessionTurnOpen, sessionRunning)
2954
+ const live = useLiveReading(
2955
+ useChat,
2956
+ geek,
2957
+ trajectory,
2958
+ runningStatsStep,
2959
+ sessionTurnOpen,
2960
+ sessionRunning,
2961
+ sessionStats,
2962
+ )
2320
2963
 
2321
2964
  const triggerRef = useRef(null)
2322
2965
  const drawerRef = useRef(null)
@@ -2414,7 +3057,18 @@ window.__ModuleLoader__.load({
2414
3057
  */
2415
3058
  const listedRows = useMemo(() => {
2416
3059
  const base = statsRows ?? fallbackRows ?? []
2417
- const visible = live === null ? base : base.filter((row) => row.key !== live.key)
3060
+ /*
3061
+ * The running compaction is rendered as its own row above the list, so the numbered
3062
+ * list must not repeat it. A finished compaction stays in the list — it is history
3063
+ * then, and it is what the drawer is for.
3064
+ */
3065
+ const runningCompactionKey =
3066
+ live !== null && live.compaction !== null && live.compaction.running === true
3067
+ ? live.compaction.key
3068
+ : null
3069
+ let visible = base
3070
+ if (live !== null && live.key !== null) visible = visible.filter((row) => row.key !== live.key)
3071
+ if (runningCompactionKey !== null) visible = visible.filter((row) => row.key !== runningCompactionKey)
2418
3072
  return visible.slice(0, MAX_ROWS)
2419
3073
  }, [statsRows, fallbackRows, live])
2420
3074
 
@@ -2472,8 +3126,30 @@ window.__ModuleLoader__.load({
2472
3126
  return widths
2473
3127
  })
2474
3128
  }
3129
+ /*
3130
+ * Publish the idle layout's width so the compaction phase can reserve it.
3131
+ *
3132
+ * The compaction reading is much shorter than five metric slots, and without a floor
3133
+ * the pill would shrink while it runs — moving the composer's neighbours and then
3134
+ * moving them back when the phase ends. Measuring the settled layout once and pinning
3135
+ * it as a CSS custom property keeps the pill the same size through every phase.
3136
+ *
3137
+ * The measurement is skipped in the compaction phases themselves: that layout is the
3138
+ * one being constrained, so measuring it would ratchet the width down to itself.
3139
+ */
3140
+ const measurePillWidth = () => {
3141
+ const anchor = triggerRef.current
3142
+ if (anchor === null) return
3143
+ const phase = anchor.getAttribute('data-live-phase')
3144
+ if (phase === 'compacting' || phase === 'compacted') return
3145
+ const width = anchor.getBoundingClientRect().width
3146
+ if (!Number.isFinite(width) || width <= 0) return
3147
+ anchor.style.setProperty('--dsh-rum-pill-width', `${Math.round(width)}px`)
3148
+ }
2475
3149
  measure()
3150
+ measurePillWidth()
2476
3151
  window.addEventListener('resize', measure)
3152
+ window.addEventListener('resize', measurePillWidth)
2477
3153
  /*
2478
3154
  * A font-size change (the Settings font scale) or a metric swap moves the pill's
2479
3155
  * cells without a window resize; observe the pill itself so the drawer columns
@@ -2487,6 +3163,7 @@ window.__ModuleLoader__.load({
2487
3163
  }
2488
3164
  return () => {
2489
3165
  window.removeEventListener('resize', measure)
3166
+ window.removeEventListener('resize', measurePillWidth)
2490
3167
  observer?.disconnect()
2491
3168
  }
2492
3169
  }, [mounted])
@@ -2600,6 +3277,22 @@ window.__ModuleLoader__.load({
2600
3277
  total: secondsText(current.totalMs, t),
2601
3278
  amortized: speedText(current.amortized, approximate, t),
2602
3279
  })}`
3280
+ /*
3281
+ * The compaction phases replace the whole set of figures with one sentence: a clock, the
3282
+ * model, the wording and a running span. That layout is built as its own branch because
3283
+ * none of the five metric slots apply — a compaction has no first token and no rate.
3284
+ */
3285
+ const compaction = live?.compaction ?? null
3286
+ const compactingPhase = live?.phase === 'compacting' || live?.phase === 'compacted'
3287
+ /*
3288
+ * One hover hint serves the whole drawer: only one can be open at a time, and sharing the
3289
+ * timer is what stops a hint from a previous row lingering while the pointer moves on.
3290
+ */
3291
+ const hint = useHint(300)
3292
+ const compactionLabel =
3293
+ compaction === null
3294
+ ? statusLabel
3295
+ : `${t('statusAria')} · ${compactionSentence(compaction, live.phase, now, t)}`
2603
3296
 
2604
3297
  return h(
2605
3298
  'div',
@@ -2609,18 +3302,52 @@ window.__ModuleLoader__.load({
2609
3302
  {
2610
3303
  ref: triggerRef,
2611
3304
  type: 'button',
2612
- className: 'dsh-rum-trigger' + (live !== null ? ' dsh-rum-live' : ''),
3305
+ className:
3306
+ 'dsh-rum-trigger' +
3307
+ (live !== null ? ' dsh-rum-live' : '') +
3308
+ (compactingPhase ? ' dsh-rum-compact-anchor' : ''),
2613
3309
  'aria-haspopup': 'dialog',
2614
3310
  'aria-expanded': open,
2615
- 'aria-label': statusLabel,
3311
+ 'aria-label': compactionLabel,
3312
+ /* The active phase, so a test can observe it without reaching into the hooks. */
3313
+ 'data-live-phase': live === null ? undefined : live.phase,
3314
+ /*
3315
+ * The compaction reading, serialised for tests. The phase attribute says which of
3316
+ * the four states is active; this says what the row actually carries — no first
3317
+ * token, no rate, a span that freezes once the fold reports `wall`.
3318
+ */
3319
+ 'data-compaction-reading':
3320
+ compaction === null || live === null
3321
+ ? undefined
3322
+ : JSON.stringify({
3323
+ compactionId: compaction.compactionId,
3324
+ running: compaction.running,
3325
+ ttftMs: live.ttftMs,
3326
+ speed: live.speed,
3327
+ totalMs: compaction.running === true ? live.totalMs : compaction.totalMs,
3328
+ startMs: compaction.startMs,
3329
+ tokens: compaction.running === true ? null : compaction.tokens,
3330
+ model: compaction.model,
3331
+ }),
3332
+ /* The four phases above are all a reader needs, but a spec also asserts the shape
3333
+ * of the compaction reading, so its identity is published too. */
3334
+ 'data-compaction': compaction === null ? undefined : compaction.compactionId,
2616
3335
  onClick: () => {
2617
3336
  setPinned((current) => !current)
2618
3337
  },
2619
3338
  },
2620
3339
  live?.phase === 'tools'
2621
3340
  ? h(ToolIcon, { className: 'dsh-rum-bolt dsh-rum-tool-icon' })
2622
- : h(BoltIcon, { className: 'dsh-rum-bolt' }),
2623
- h(
3341
+ : compactingPhase
3342
+ ? h(CompactionIcon, {
3343
+ className:
3344
+ 'dsh-rum-bolt dsh-rum-compaction-icon' +
3345
+ (live.phase === 'compacting' ? '' : ' dsh-rum-still'),
3346
+ })
3347
+ : h(BoltIcon, { className: 'dsh-rum-bolt' }),
3348
+ compactingPhase && compaction !== null
3349
+ ? h(CompactionReading, { compaction, phase: live.phase, now, t })
3350
+ : h(
2624
3351
  'span',
2625
3352
  { className: 'dsh-rum-parts', ref: partsRef },
2626
3353
  h(
@@ -2658,6 +3385,12 @@ window.__ModuleLoader__.load({
2658
3385
  ),
2659
3386
  ),
2660
3387
  ),
3388
+ h(Tooltip, {
3389
+ open: hint.hint !== null,
3390
+ x: hint.hint?.x ?? 0,
3391
+ y: hint.hint?.y ?? 0,
3392
+ children: hint.hint?.content ?? null,
3393
+ }),
2661
3394
  mounted
2662
3395
  ? portal(
2663
3396
  h(
@@ -2717,19 +3450,76 @@ window.__ModuleLoader__.load({
2717
3450
  { className: 'dsh-rum-row', 'data-head': true, role: 'row' },
2718
3451
  h(
2719
3452
  'span',
2720
- { className: 'dsh-rum-col', role: 'columnheader', 'data-align': 'start' },
3453
+ {
3454
+ className: 'dsh-rum-col',
3455
+ role: 'columnheader',
3456
+ 'data-align': 'start',
3457
+ onMouseEnter: (event) => hint.show(indexHint(t), event),
3458
+ onMouseLeave: hint.hide,
3459
+ },
2721
3460
  t('colIndex'),
2722
3461
  ),
2723
3462
  h(
2724
3463
  'span',
2725
- { className: 'dsh-rum-col', role: 'columnheader', 'data-align': 'start' },
3464
+ {
3465
+ className: 'dsh-rum-col',
3466
+ role: 'columnheader',
3467
+ 'data-align': 'start',
3468
+ onMouseEnter: (event) => hint.show(t('hintTime'), event),
3469
+ onMouseLeave: hint.hide,
3470
+ },
2726
3471
  t('colTime'),
2727
3472
  ),
2728
- h('span', { className: 'dsh-rum-col', role: 'columnheader' }, t('colTtft')),
2729
- h('span', { className: 'dsh-rum-col', role: 'columnheader' }, t('colSpeed')),
2730
- h('span', { className: 'dsh-rum-col', role: 'columnheader' }, t('colTokens')),
2731
- h('span', { className: 'dsh-rum-col', role: 'columnheader' }, t('colTotal')),
2732
- h('span', { className: 'dsh-rum-col', role: 'columnheader' }, t('colAmortized')),
3473
+ h(
3474
+ 'span',
3475
+ {
3476
+ className: 'dsh-rum-col',
3477
+ role: 'columnheader',
3478
+ onMouseEnter: (event) => hint.show(t('hintTtft'), event),
3479
+ onMouseLeave: hint.hide,
3480
+ },
3481
+ t('colTtft'),
3482
+ ),
3483
+ h(
3484
+ 'span',
3485
+ {
3486
+ className: 'dsh-rum-col',
3487
+ role: 'columnheader',
3488
+ onMouseEnter: (event) => hint.show(t('hintSpeed'), event),
3489
+ onMouseLeave: hint.hide,
3490
+ },
3491
+ t('colSpeed'),
3492
+ ),
3493
+ h(
3494
+ 'span',
3495
+ {
3496
+ className: 'dsh-rum-col',
3497
+ role: 'columnheader',
3498
+ onMouseEnter: (event) => hint.show(t('hintTokens'), event),
3499
+ onMouseLeave: hint.hide,
3500
+ },
3501
+ t('colTokens'),
3502
+ ),
3503
+ h(
3504
+ 'span',
3505
+ {
3506
+ className: 'dsh-rum-col',
3507
+ role: 'columnheader',
3508
+ onMouseEnter: (event) => hint.show(t('hintTotal'), event),
3509
+ onMouseLeave: hint.hide,
3510
+ },
3511
+ t('colTotal'),
3512
+ ),
3513
+ h(
3514
+ 'span',
3515
+ {
3516
+ className: 'dsh-rum-col',
3517
+ role: 'columnheader',
3518
+ onMouseEnter: (event) => hint.show(t('hintAmortized'), event),
3519
+ onMouseLeave: hint.hide,
3520
+ },
3521
+ t('colAmortized'),
3522
+ ),
2733
3523
  ),
2734
3524
  live !== null
2735
3525
  ? h(
@@ -2757,17 +3547,21 @@ window.__ModuleLoader__.load({
2757
3547
  */
2758
3548
  className: 'dsh-rum-col dsh-rum-index',
2759
3549
  role: 'cell',
2760
- 'aria-label': t('drawerLive'),
2761
- title: t('drawerLive'),
3550
+ 'aria-label':
3551
+ live.compaction !== null && live.compaction !== undefined
3552
+ ? t('drawerCompacting')
3553
+ : t('drawerLive'),
2762
3554
  },
2763
3555
  /*
2764
- * The same bolt the status pill uses while a request streams, so the
2765
- * marker reads as one idea in both places rather than a dot here and
2766
- * a bolt there.
3556
+ * The same glyphs the status pill uses, so a marker means one thing
3557
+ * wherever it appears: the bolt for a reply, the T for its tools,
3558
+ * the three bars for a compaction.
2767
3559
  */
2768
- live.phase === 'tools'
2769
- ? h(ToolIcon, { className: 'dsh-rum-live-bolt dsh-rum-tool-icon' })
2770
- : h(BoltIcon, { className: 'dsh-rum-live-bolt' }),
3560
+ live.compaction !== null && live.compaction !== undefined
3561
+ ? h(CompactionIcon, { className: 'dsh-rum-live-bolt dsh-rum-compaction-icon' })
3562
+ : live.phase === 'tools'
3563
+ ? h(ToolIcon, { className: 'dsh-rum-live-bolt dsh-rum-tool-icon' })
3564
+ : h(BoltIcon, { className: 'dsh-rum-live-bolt' }),
2771
3565
  ),
2772
3566
  /*
2773
3567
  * The clock, not the word "streaming".
@@ -2793,10 +3587,21 @@ window.__ModuleLoader__.load({
2793
3587
  h(
2794
3588
  'span',
2795
3589
  { className: 'dsh-rum-col dsh-rum-primary', role: 'cell' },
2796
- speedText(live.speed, live.estimated === true, t),
3590
+ live.compaction !== null && live.compaction !== undefined
3591
+ ? t('compactionSpeed')
3592
+ : speedText(live.speed, live.estimated === true, t),
2797
3593
  ),
2798
3594
  h('span', { className: 'dsh-rum-col', role: 'cell' }, tokensText(live.tokens, live.estimated === true, t)),
2799
- h('span', { className: 'dsh-rum-col', role: 'cell' }, secondsText(live.totalMs, t)),
3595
+ h(
3596
+ 'span',
3597
+ { className: 'dsh-rum-col', role: 'cell' },
3598
+ secondsText(
3599
+ live.compaction !== null && live.compaction !== undefined
3600
+ ? compactionSpan(live.compaction, now)
3601
+ : live.totalMs,
3602
+ t,
3603
+ ),
3604
+ ),
2800
3605
  h(
2801
3606
  'span',
2802
3607
  { className: 'dsh-rum-col', role: 'cell' },
@@ -2809,45 +3614,106 @@ window.__ModuleLoader__.load({
2809
3614
  'div',
2810
3615
  {
2811
3616
  key: entry.key,
2812
- className: 'dsh-rum-row dsh-rum-item',
3617
+ className:
3618
+ 'dsh-rum-row dsh-rum-item' +
3619
+ (entry.row.compaction === true ? ' dsh-rum-compaction-row' : ''),
2813
3620
  'data-phase': entry.phase,
2814
3621
  role: 'row',
2815
3622
  },
2816
- h('span', { className: 'dsh-rum-index', role: 'cell' }, String(entry.index + 1)),
3623
+ /*
3624
+ * The index column shows the phase glyph instead of a number while a
3625
+ * request or a compaction is still running: the row is not a finished
3626
+ * record yet, so numbering it would imply it belongs with the ten.
3627
+ */
2817
3628
  h(
2818
3629
  'span',
2819
- { className: 'dsh-rum-clock', role: 'cell', title: agoText(entry.row.at, now, t) },
3630
+ { className: 'dsh-rum-index', role: 'cell' },
3631
+ entry.row.compaction === true
3632
+ ? h(CompactionIcon, {
3633
+ className:
3634
+ 'dsh-rum-live-bolt dsh-rum-compaction-icon' +
3635
+ (entry.row.running === true ? '' : ' dsh-rum-still'),
3636
+ })
3637
+ : entry.phase === 'tools'
3638
+ ? h(ToolIcon, { className: 'dsh-rum-live-bolt dsh-rum-tool-icon' })
3639
+ : entry.phase === 'writing'
3640
+ ? h(BoltIcon, { className: 'dsh-rum-live-bolt' })
3641
+ : String(entry.index + 1),
3642
+ ),
3643
+ /*
3644
+ * The clock's hint is the absolute timestamp: the relative wording the
3645
+ * cell used to carry in `title` moved into the tooltip once the drawer
3646
+ * gained one, and "3 minutes ago" cannot answer "which one was that".
3647
+ */
3648
+ h(
3649
+ 'span',
3650
+ {
3651
+ className: 'dsh-rum-clock',
3652
+ role: 'cell',
3653
+ onMouseEnter: (event) => hint.show(absoluteStamp(entry.row.at), event),
3654
+ onMouseLeave: hint.hide,
3655
+ },
2820
3656
  clockText(entry.row.at),
2821
3657
  ),
2822
3658
  h(
2823
3659
  'span',
2824
3660
  { className: 'dsh-rum-col', role: 'cell' },
2825
3661
  /*
2826
- * The first-token column reports whatever was actually measured. An
2827
- * interrupted request that had already produced its first token shows
2828
- * that real figure; one stopped before any token shows the dash.
3662
+ * A compaction has no first token, so the column is always the dash.
3663
+ * An interrupted request keeps whatever was measured: one stopped
3664
+ * after its first token shows that real figure.
2829
3665
  */
2830
- secondsText(entry.row.ttftMs, t),
3666
+ secondsText(entry.row.compaction === true ? null : entry.row.ttftMs, t),
2831
3667
  ),
2832
3668
  h(
2833
3669
  'span',
2834
- { className: 'dsh-rum-col dsh-rum-primary', role: 'cell' },
3670
+ {
3671
+ className: 'dsh-rum-col dsh-rum-primary',
3672
+ role: 'cell',
3673
+ /*
3674
+ * The hint names the model that served this row. A compaction's
3675
+ * summarizer reuses the routed model, and `compaction/summary`
3676
+ * records it, so both kinds of row can answer.
3677
+ */
3678
+ onMouseEnter: (event) => hint.show(modelHintText(entry.row, t), event),
3679
+ onMouseLeave: hint.hide,
3680
+ },
2835
3681
  /*
2836
- * A cancelled step never settled, so it has no generation speed. The
2837
- * column says what happened instead of the same dash a step that has
2838
- * not started would show — the speed column is where "this produced no
2839
- * rate" belongs, while first-token and total still carry real figures.
3682
+ * A cancelled step never settled and a compaction never generates, so
3683
+ * neither has a rate. The column says what happened instead of the same
3684
+ * dash a request that has not started would show.
2840
3685
  */
2841
- entry.row.interrupted === true
2842
- ? t('interrupted')
2843
- : speedText(entry.row.speed, false, t),
3686
+ entry.row.compaction === true
3687
+ ? t('compactionSpeed')
3688
+ : entry.row.interrupted === true
3689
+ ? t('interrupted')
3690
+ : speedText(entry.row.speed, false, t),
2844
3691
  ),
2845
3692
  h(
2846
3693
  'span',
2847
- { className: 'dsh-rum-col', role: 'cell' },
3694
+ {
3695
+ className: 'dsh-rum-col',
3696
+ role: 'cell',
3697
+ onMouseEnter: (event) => hint.show(tokensHintText(entry.row, t), event),
3698
+ onMouseLeave: hint.hide,
3699
+ },
2848
3700
  tokensText(entry.row.tokens, entry.row.estimated, t),
2849
3701
  ),
2850
- h('span', { className: 'dsh-rum-col', role: 'cell' }, secondsText(entry.row.totalMs, t)),
3702
+ h(
3703
+ 'span',
3704
+ { className: 'dsh-rum-col', role: 'cell' },
3705
+ secondsText(
3706
+ /*
3707
+ * A running compaction counts up from its own start, exactly as the
3708
+ * pill does, so the two figures cannot diverge; the frozen `wall`
3709
+ * takes over the moment the fold reports it.
3710
+ */
3711
+ entry.row.compaction === true && entry.row.running === true
3712
+ ? compactionSpan(entry.row, now)
3713
+ : entry.row.totalMs,
3714
+ t,
3715
+ ),
3716
+ ),
2851
3717
  h(
2852
3718
  'span',
2853
3719
  { className: 'dsh-rum-col', role: 'cell' },