dsh-activity-pane 0.3.0 → 0.4.0

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.
@@ -1073,6 +1073,31 @@ function usageSummary({ uncachedInputTokens = null, cacheReadTokens = null, cach
1073
1073
  return { inputTokens, cacheHitPct };
1074
1074
  }
1075
1075
 
1076
+ /** 从 sessions.list 的投影统一归一卡片统计:运行卡与最近卡共享同一口径。 */
1077
+ function statsFromProjection(projection = null, elapsedMs = null) {
1078
+ const usage = isRecord(projection?.tokenUsage) ? projection.tokenUsage : {};
1079
+ const sessionStats = isRecord(projection?.sessionStats) ? projection.sessionStats : {};
1080
+ const rateTokS = Number.isFinite(sessionStats.decodeMs) && sessionStats.decodeMs > 0
1081
+ ? sessionStats.decodeTokens / (sessionStats.decodeMs / 1000)
1082
+ : null;
1083
+ return {
1084
+ ...runtimeStats({ elapsedMs, outputTokens: usage.outputTokens, rateTokS }),
1085
+ ...usageSummary(usage),
1086
+ };
1087
+ }
1088
+
1089
+ /** 统计字段按字段合并:当前值有效时优先,否则回退到上一份已知值;不合并 elapsedMs。 */
1090
+ function mergeRuntimeStats(current = null, fallback = null) {
1091
+ const currentStats = isRecord(current) ? current : {};
1092
+ const fallbackStats = isRecord(fallback) ? fallback : {};
1093
+ return {
1094
+ outputTokens: currentStats.outputTokens ?? fallbackStats.outputTokens ?? null,
1095
+ inputTokens: currentStats.inputTokens ?? fallbackStats.inputTokens ?? null,
1096
+ cacheHitPct: currentStats.cacheHitPct ?? fallbackStats.cacheHitPct ?? null,
1097
+ rateTokS: currentStats.rateTokS ?? fallbackStats.rateTokS ?? null,
1098
+ };
1099
+ }
1100
+
1076
1101
  /** 需要用户行动的种类的展示文案。 */
1077
1102
  function pendingText(kind) {
1078
1103
  return PENDING_LABELS[kind] ?? PENDING_UNKNOWN_LABEL;
@@ -1174,8 +1199,8 @@ function escapeCssString(value) {
1174
1199
  * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1175
1200
  * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1176
1201
  * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1177
- * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡
1178
- * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12)。 */
1202
+ * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡或最近卡
1203
+ * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12、R-01-013/AC-12)。 */
1179
1204
  function detailLoadPlan({
1180
1205
  detail = {},
1181
1206
  isSubagent = false,
@@ -2649,14 +2674,10 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2649
2674
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
2650
2675
  min-width: 0; margin: 2px 0 0;
2651
2676
  }
2652
- /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
2677
+ /* 等待类型胶囊独立同行:耗时回到统计行最右侧,不参与等待脉冲。 */
2653
2678
  [data-dsh-activity-pane] .dap-await-head {
2654
2679
  display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
2655
2680
  }
2656
- [data-dsh-activity-pane] .dap-await-head .dap-token-time {
2657
- margin-left: auto; flex: none; font-size: 10px; line-height: 14px;
2658
- color: #8f9aaa; font-variant-numeric: tabular-nums;
2659
- }
2660
2681
  [data-dsh-activity-pane] .dap-note-row {
2661
2682
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
2662
2683
  }
@@ -2675,7 +2696,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2675
2696
  color: color-mix(in srgb, currentColor 82%, transparent);
2676
2697
  background: color-mix(in srgb, currentColor 14%, transparent);
2677
2698
  }
2678
- /* 运行卡富化(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
2699
+ /* 运行卡与最近卡统计(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
2679
2700
  [data-dsh-activity-pane] .dap-progress {
2680
2701
  display: flex; align-items: center; gap: 7px; min-width: 0;
2681
2702
  }
@@ -3214,7 +3235,7 @@ function apply(ctx) {
3214
3235
  const livenessById = new Map();
3215
3236
  /** 委托周期进度锚点记账:id → progressAnchor 状态(R-01-009/AC-06)。 */
3216
3237
  const progressAnchorById = new Map();
3217
- /** 订阅停止后保留最近快照,供 awaiting/recent 卡继续显示上下文。 */
3238
+ /** 订阅停止后保留最近快照与最后已知运行统计,供 awaiting/recent 卡继续显示上下文。 */
3218
3239
  const sessionDetailsById = new Map();
3219
3240
  /** 会话跳转的单一重试链;避免重复点击叠加 refresh/timer。 */
3220
3241
  const openRetryStates = new Map();
@@ -3889,7 +3910,10 @@ function apply(ctx) {
3889
3910
  const agentLabel = makeEl("span", "dap-history-label");
3890
3911
  agentLabel.textContent = "助手";
3891
3912
  agentLine.append(agentIcon, agentLabel, makeEl("span", "dap-history-separator"), makeEl("span", "dap-history-text"));
3892
- return [head, row, userLine, agentLine, makeEl("div", "dap-note")];
3913
+ const statsRow = makeEl("div", "dap-token-stats");
3914
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
3915
+ statsRow.hidden = true;
3916
+ return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];
3893
3917
  }
3894
3918
  if (kind === "awaiting") {
3895
3919
  const row = makeEl("div", "dap-row");
@@ -3903,10 +3927,13 @@ function apply(ctx) {
3903
3927
  const noteRow = makeEl("div", "dap-note-row");
3904
3928
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
3905
3929
  const awaitHead = makeEl("div", "dap-await-head");
3906
- awaitHead.append(capsule, makeEl("span", "dap-token-time"));
3930
+ awaitHead.append(capsule);
3931
+ const statsRow = makeEl("div", "dap-token-stats");
3932
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
3933
+ statsRow.hidden = true;
3907
3934
  const foot = makeEl("div", "dap-foot");
3908
3935
  foot.append(awaitHead, noteRow);
3909
- return [head, row, makeEl("div", "dap-trace"), foot];
3936
+ return [head, row, makeEl("div", "dap-trace"), statsRow, foot];
3910
3937
  }
3911
3938
  const row = makeEl("div", "dap-row");
3912
3939
  row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
@@ -4267,14 +4294,19 @@ function apply(ctx) {
4267
4294
  }
4268
4295
  }
4269
4296
 
4270
- /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
4297
+ /** 统计行渲染:运行卡、等待卡与最近卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
4271
4298
  function renderTokenStats(el, entry) {
4272
4299
  let stats = el.querySelector(".dap-token-stats");
4273
4300
  if (stats === null) {
4274
4301
  stats = makeEl("div", "dap-token-stats");
4275
4302
  stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
4276
4303
  stats.hidden = true;
4277
- el.append(stats);
4304
+ const foot = entry.kind === "awaiting" ? el.querySelector(".dap-foot") : null;
4305
+ if (foot !== null) el.insertBefore(stats, foot);
4306
+ else el.append(stats);
4307
+ } else if (entry.kind === "awaiting") {
4308
+ const foot = el.querySelector(".dap-foot");
4309
+ if (foot !== null && stats.nextElementSibling !== foot) el.insertBefore(stats, foot);
4278
4310
  }
4279
4311
  let mainTextEl = stats.querySelector(".dap-token-main");
4280
4312
  let timeEl = stats.querySelector(".dap-token-time");
@@ -4296,25 +4328,9 @@ function apply(ctx) {
4296
4328
  if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
4297
4329
  }
4298
4330
 
4299
- /** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
4300
- function renderAwaitingDuration(el, entry) {
4301
- const foot = el.querySelector(".dap-foot");
4302
- if (foot === null) return;
4303
- let head = foot.querySelector(".dap-await-head");
4304
- if (head === null) {
4305
- const capsule = foot.querySelector(".dap-capsule");
4306
- if (capsule === null) return;
4307
- head = makeEl("div", "dap-await-head");
4308
- capsule.replaceWith(head);
4309
- head.append(capsule);
4310
- }
4311
- let time = head.querySelector(".dap-token-time");
4312
- if (time === null) {
4313
- time = makeEl("span", "dap-token-time");
4314
- head.append(time);
4315
- }
4316
- const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
4317
- if (time.textContent !== text) time.textContent = text;
4331
+ /** 旧热装等待骨架清理:耗时已迁回统计行,去掉胶囊同行残留的时长节点。 */
4332
+ function removeAwaitingHeadDuration(el) {
4333
+ el.querySelector(".dap-await-head .dap-token-time")?.remove();
4318
4334
  }
4319
4335
 
4320
4336
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
@@ -4449,12 +4465,14 @@ function apply(ctx) {
4449
4465
  restoreTextField(text, previews[i]);
4450
4466
  }
4451
4467
  }
4468
+ renderTokenStats(el, entry);
4452
4469
  }
4453
4470
 
4454
4471
  if (entry.kind === "awaiting") {
4455
4472
  const traceContainer = el.querySelector(".dap-trace");
4456
4473
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
4457
- renderAwaitingDuration(el, entry);
4474
+ removeAwaitingHeadDuration(el);
4475
+ renderTokenStats(el, entry);
4458
4476
  const confirm = el.querySelector(".dap-confirm");
4459
4477
  if (confirm !== null) {
4460
4478
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -5099,11 +5117,11 @@ function apply(ctx) {
5099
5117
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
5100
5118
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
5101
5119
  const projection = snapshot?.byId?.[entry.id]?.projectionValues;
5102
- const stats = projection?.sessionStats;
5103
- const rateTokS =
5104
- stats && Number.isFinite(stats.decodeMs) && stats.decodeMs > 0
5105
- ? stats.decodeTokens / (stats.decodeMs / 1000)
5106
- : null;
5120
+ const projectionStats = statsFromProjection(projection);
5121
+ if (entry.kind === "awaiting") {
5122
+ // 等待期间冻结运行停止前的最后已知统计;刷新/无留存时回退当前列表投影。
5123
+ Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
5124
+ }
5107
5125
  // 委托周期锚点(R-01-009/AC-06):全部活动条目逐帧记账——锚点不因呈现翻转
5108
5126
  // (委托期与 awaiting 互转)或瞬时不可见而丢失,仅 dispose 时整体清除;周期内
5109
5127
  // 进度连续(含 settle 处理回合),周期外由宿主回合起点驱动(新回合归零)。
@@ -5119,13 +5137,15 @@ function apply(ctx) {
5119
5137
  progressAnchorById.set(entry.id, anchor);
5120
5138
  if (entry.kind === "running") {
5121
5139
  const elapsedMs = Number.isFinite(anchor.anchor) ? Math.max(0, now - anchor.anchor) : null;
5122
- const outputTokens = projection?.tokenUsage?.outputTokens ?? null;
5123
- Object.assign(entry, runtimeStats({ elapsedMs, outputTokens, rateTokS }));
5124
- Object.assign(entry, usageSummary(projection?.tokenUsage ?? {}));
5140
+ Object.assign(entry, projectionStats, { elapsedMs });
5141
+ if (detail) {
5142
+ // 列表投影短暂缺字段时,不覆盖此前已知的有效统计。
5143
+ detail.lastRuntimeStats = mergeRuntimeStats(projectionStats, detail.lastRuntimeStats);
5144
+ }
5125
5145
  // 回合进度:纯时间驱动 y = t/(t+k),半衰期每帧按最新实测速率现算,固定
5126
5146
  // k 下单调性由函数本身保证;k 变化时允许进度随速率回落而回退(委托周期
5127
5147
  // 连续、周期外回合切换归零,R-01-009/AC-06,C-014、C-044)。
5128
- entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS }) });
5148
+ entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
5129
5149
  }
5130
5150
  }
5131
5151
  // 历史区时间精化(R-01-010/AC-08、AC-09):从保留快照的 turnTimings 与已拉取的
@@ -5146,6 +5166,23 @@ function apply(ctx) {
5146
5166
  recentTotal = recentCandidates.length;
5147
5167
  const recent = recentCandidates.slice(0, recentVisibleCount);
5148
5168
  recentHasMore = recent.length < recentTotal;
5169
+ // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
5170
+ // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
5171
+ const recentDurationFallbackIds = new Set();
5172
+ for (const entry of recent) {
5173
+ const detail = sessionDetailsById.get(entry.id);
5174
+ const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
5175
+ const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
5176
+ const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
5177
+ const retained = detail?.lastRuntimeStats;
5178
+ Object.assign(entry, stats, retained ? {
5179
+ outputTokens: retained.outputTokens ?? stats.outputTokens,
5180
+ inputTokens: retained.inputTokens ?? stats.inputTokens,
5181
+ cacheHitPct: retained.cacheHitPct ?? stats.cacheHitPct,
5182
+ rateTokS: retained.rateTokS ?? stats.rateTokS,
5183
+ } : null);
5184
+ if (elapsedMs === null) recentDurationFallbackIds.add(entry.id);
5185
+ }
5149
5186
  syncRecentTimeClock(recent.length > 0);
5150
5187
  // 预览只对当前显示的 recent 卡计算(活动卡不显示预览);快照/历史引用不变时命中缓存。
5151
5188
  // 完成瞬间的窗口快照可能先有用户消息、后到 agent reply;缺任一预览时补读一次 history。
@@ -5166,7 +5203,10 @@ function apply(ctx) {
5166
5203
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
5167
5204
  }
5168
5205
  // 补充数据读取优先级:当前会话最优先,活动区先于当前已显示历史页(区内按显示顺序)。
5169
- const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
5206
+ const durationFallbackIds = new Set([
5207
+ ...active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id),
5208
+ ...recentDurationFallbackIds,
5209
+ ]);
5170
5210
  const detailIds = [...active, ...recent].map((entry) => entry.id);
5171
5211
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
5172
5212
  loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
package/README.md CHANGED
@@ -52,7 +52,7 @@ The npm package ships prebuilt, so no local build step is needed. If the pane do
52
52
  - [x] **Stronger waiting-for-action reminders**: blocked waits, completion reminders, and error reminders are marked with gold, green, and red cards respectively; questions are previewed directly as a question list, and completion reminders are explicitly acknowledged via the "Move to history" button on the card; the state is persisted on the host side and synced across all clients, so refreshing the page or opening another window never loses unacknowledged completion reminders or error reminders not yet overwritten by a new round.
53
53
  - [x] **Sub/grandchild session hierarchy**: sub-agents are nested under their parent session with connector lines and compact cards; a parent whose own round has ended but that still has active descendants keeps rendering as running, and disappears from the active area once its sub-agents have ended and no active descendants remain; the history area keeps main sessions only.
54
54
  - [x] **Workspace names displayed and factored into ordering**: session cards show a workspace badge with a stable color, and session ordering follows the workspace order in the left sidebar.
55
- - [x] **Current work and run stats**: active cards show the latest instruction, thinking, and tool calls in a collapsed timeline of at most 4 rows; running cards also show round progress, output rate, cache hit rate, input/output tokens, and run duration, while completed, blocked, and error waits retain the previous round's duration.
55
+ - [x] **Current work and run stats**: active cards show the latest instruction, thinking, and tool calls in a collapsed timeline of at most 4 rows; running cards also show round progress, output rate, cache hit rate, input/output tokens, and run duration, while completed, blocked, and error waits retain the previous round's duration at the end of the token stats row alongside last-known output stats; recent history cards retain the latest completed round's available stats between the reply preview and activity time.
56
56
  - [x] **Session navigation**: clicking or keyboard-activating a session card jumps to that session's page, and the current session stays highlighted.
57
57
  - [x] **Session metadata**: session cards show the current model name and reasoning level.
58
58
  - [x] **Polished desktop & mobile interactions**: the desktop pane can collapse, be resized by dragging, and remember its width; mobile uses a fixed drawer that does not squeeze the main conversation layout; long lists get independent scrolling and a back-to-top button.
package/README.zh-CN.md CHANGED
@@ -52,7 +52,7 @@ npm 包内置预构建产物,无需本地构建步骤。安装后如窗格未
52
52
  - [x] **强化等待行动提醒**:阻塞等待、完成提醒与错误提醒分别以金色、绿色和红色卡片标识;提问直接预览问题列表,完成提醒经卡片上的「移入历史」按钮显式确认;状态由宿主侧持久化并在所有客户端间同步,刷新页面或另开窗口不会丢失未确认的完成提醒和尚未被新回合覆盖的错误提醒。
53
53
  - [x] **显示子/孙会话层级**:子代理以连接线和紧凑卡片嵌套在母会话下;母会话自身回合结束但仍有活动后代时继续按运行中呈现,子代理结束且没有活动后代后从活动区消失;历史区只保留主会话。
54
54
  - [x] **显示工作区名称并参与排序**:会话卡片显示带稳定色彩的工作区徽标,会话排序与左侧边栏中的工作区顺序保持一致。
55
- - [x] **展示当前工作与运行统计**:活动卡以最多 4 行折叠时间线展示最近指令、思考与工具调用;运行中卡片还显示回合进度、输出速率、缓存命中率、输入/输出 token 与运行时长,进入完成、阻塞或错误等待后保留上一轮耗时。
55
+ - [x] **展示当前工作与运行统计**:活动卡以最多 4 行折叠时间线展示最近指令、思考与工具调用;运行中卡片还显示回合进度、输出速率、缓存命中率、输入/输出 token 与运行时长,进入完成、阻塞或错误等待后在统计行末尾与 tok/s 等内容并列保留上一轮耗时及最后已知统计;迁入最近历史后,历史卡在助手预览与活动时间之间继续保留最后一轮的可用统计。
56
56
  - [x] **加入会话导航跳转**:点击或键盘激活会话卡片可跳转到对应会话页面,当前会话保持高亮。
57
57
  - [x] **增加会话元信息**:会话卡片中显示当前使用的模型名称和推理级别。
58
58
  - [x] **完善桌面与移动交互**:桌面窗格可折叠、拖拽调宽并记忆宽度;移动端使用不挤压主会话布局的固定抽屉;长列表提供独立滚动与回到顶部按钮。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-activity-pane",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -21,6 +21,7 @@
21
21
  // - R-01-015/AC-01、AC-02、AC-04、AC-03 桌面折叠态 → e2e/specs/resize.mjs;AC-03 移动端无手柄 → e2e/specs/mobile-drawer.mjs
22
22
  // - R-01-018/AC-01、AC-03、AC-04、AC-05、AC-02 点击/键盘/reduced-motion → e2e/specs/back-to-top.mjs(AC-02 平滑观感残留人工)
23
23
  // - R-01-003/AC-03、AC-08~AC-12 浏览器着色、R-01-012/AC-01、AC-05、R-01-013/AC-01、R-01-013/AC-03、R-01-013/AC-04、AC-07、AC-08、AC-10、AC-11 → e2e/specs/card-content.mjs(AC-02 结构断言已迁,多工作区感知区分与「标题随宿主更新」半句仍人工)
24
+ // - R-01-013/AC-12 → e2e/specs/session-lifecycle.mjs(最近卡统计行迁移与位置)
24
25
  // T-089 起:
25
26
  // - R-01-009/AC-02、AC-03 的 tool→等待→stream 实时更新 → e2e/specs/auto-update.mjs(AC-01 进行中 tool partial 仍由 unit/人工承接)。
26
27
  // - R-01-014/AC-01、AC-03、AC-06 的列表 pending→ready、detail 渐进补齐与计数切换 → e2e/specs/loading-ready.mjs。
@@ -65,8 +66,8 @@ const steps = [
65
66
  "让包含 todo_write/ask_user_question/cordis_define/失败工具调用/上下文注入的会话在选中与非选中态间切换,确认时间线中每个动作的标题与摘要两态完全一致(todo 显示 x/y 进度、ask 显示等待/已答/已取消、失败动作显示错误首行、上下文注入显示「上下文注入 · 来源标识」而非注入内容原文)。",
66
67
  // R-01-009/AC-05 统计行布局与缓存命中率
67
68
  "运行中会话流式输出时,确认进度条下方统计行左侧依次显示「tok/s 速率 · 缓存命中率 · 输入 · 输出」(与会话主窗口同序),字段间以小圆点区隔且速率无约等于符号、命中率为百分比;本回合时长固定在该行最右侧;数据缺失的字段隐藏,两段皆空时整行不残留。",
68
- // R-01-009/AC-12 等待状态保留上一轮耗时
69
- "让已有至少一个完整已结束回合的会话分别进入完成提醒、待确认/待审查/待回复阻塞等待与错误提醒,确认三类等待卡的等待类型胶囊同行最右侧均继续显示上一轮完整回合耗时;等待数分钟后文字不增长;没有完整上一轮时不显示虚假耗时。",
69
+ // R-01-009/AC-12、R-01-009/AC-13 等待状态保留上一轮耗时与统计
70
+ "让已有至少一个完整已结束回合的会话分别进入完成提醒、待确认/待审查/待回复阻塞等待与错误提醒,确认三类等待卡的时间线之后均继续显示上一轮最后已知的「tok/s · 缓存 · 输入 · 输出」可用统计,且耗时与上述统计处于同一行最右侧、等待类型胶囊同行不重复显示;等待数分钟后统计与耗时均不增长;没有可用统计或完整上一轮时不显示虚假字段。",
70
71
  // R-01-009/AC-06 回合进度条(时间驱动、半衰期每帧按最新实测速率校准,C-014、C-025、C-044)
71
72
  "确认运行卡标题后不再显示进度百分比;百分比改为与进度条同行并紧跟其右侧,在固定占位内向右对齐,其文字右缘与下一行最右侧的本回合耗时文字右缘对齐,并相对进度条中心略微上移 1px、视觉上不再偏下;9% 到 100% 的位数变化不改变其占位宽度或右缘,窄卡宽下进度条可收缩而百分比保持完整可见。观察进度条随本回合已耗时平滑爬升(不再出现 10% 级跳变):新会话起步期(无实测速率)按 20 tok/s 保守基准慢爬(约 2 分钟到 18%、6 分钟到 40%);速率实测后进度增速随统计行显示的累计平均速率动态调整——平均速率上升时增速加快(如 ≈45 tok/s 时 4 分钟到 50%),平均速率回落时进度可随之小幅回退(实时估计语义,不承诺单调);回合切换后归零重爬;中途才接入的会话进度从该回合已耗时对应值起算;回退时条纹动画不中断。",
72
73
  // R-01-009/AC-07 工作项时间线状态与摘要(无行级耗时)
@@ -130,6 +131,8 @@ const steps = [
130
131
  // R-01-010/AC-08、R-01-010/AC-09 历史区时间口径为最后活动时间
131
132
  // R-01-013/AC-05 历史卡同时显示绝对日期时间与相对活动时间
132
133
  "找一个发指令后 agent 运行了较久才结束的会话,待其落入最近历史区后,确认卡片第五行同时显示本地绝对日期时间(跨年份带年份)与相对活动时间,且绝对时间对应最后回复结束时刻(而非发指令时刻);历史区仍按该时刻从新到旧排序。刷新页面(冷会话)后确认时间先按宿主列表时间显示、数据到达后精化为回复结束时刻,相对时间会按分钟级推进。",
134
+ // R-01-013/AC-12 最近历史卡保留最近回合统计
135
+ "让一个已有完整回合的会话结束并经「移入历史」进入最近历史区,确认助手预览行之后、活动时间之前的倒数第二行仍显示最后回合的「tok/s · 缓存 · 输入 · 输出」可用统计,耗时固定在该行最右侧;缺失字段不造假且全部为空时不留空行。刷新页面后确认可从 native 列表投影与回合边界恢复。",
133
136
  // R-01-013/AC-09 最近卡标题常规字重
134
137
  "确认最近历史卡的会话标题为常规字重(不加粗),与活动卡的加粗标题形成明显区分。",
135
138
  // R-01-013/AC-10 最近卡整体淡化
package/scripts/check.mjs CHANGED
@@ -66,6 +66,8 @@ import {
66
66
  pruneInvisibleEntries,
67
67
  pruneSubscriptions,
68
68
  runtimeStats,
69
+ statsFromProjection,
70
+ mergeRuntimeStats,
69
71
  shouldCancelOpenRetry,
70
72
  subagentTitle,
71
73
  summarizeToolArguments,
@@ -1502,6 +1504,33 @@ assert.deepEqual(
1502
1504
  { elapsedMs: 47_000, outputTokens: 0, rateTokS: null },
1503
1505
  "零速率归一为空,token 统计仍可放在进度条下方",
1504
1506
  );
1507
+ assert.deepEqual(
1508
+ statsFromProjection(
1509
+ {
1510
+ tokenUsage: { uncachedInputTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 10, outputTokens: 144 },
1511
+ sessionStats: { decodeTokens: 144, decodeMs: 3_000 },
1512
+ },
1513
+ 47_000,
1514
+ ),
1515
+ { elapsedMs: 47_000, outputTokens: 144, rateTokS: 48, inputTokens: 130, cacheHitPct: 15 },
1516
+ "最近卡复用列表投影派生 tok/s、缓存、输入/输出与固定耗时(R-01-013/AC-12)",
1517
+ );
1518
+ assert.deepEqual(
1519
+ mergeRuntimeStats(
1520
+ { outputTokens: null, inputTokens: 24, cacheHitPct: null, rateTokS: null },
1521
+ { outputTokens: 96, inputTokens: 12, cacheHitPct: 40, rateTokS: 8 },
1522
+ ),
1523
+ { outputTokens: 96, inputTokens: 24, cacheHitPct: 40, rateTokS: 8 },
1524
+ "等待/运行统计按字段回退,缺失投影不覆盖最后已知有效值(R-01-009/AC-13)",
1525
+ );
1526
+ assert.deepEqual(
1527
+ mergeRuntimeStats(
1528
+ { outputTokens: 0, inputTokens: 0, cacheHitPct: 0, rateTokS: null },
1529
+ { outputTokens: 96, inputTokens: 12, cacheHitPct: 40, rateTokS: 8 },
1530
+ ),
1531
+ { outputTokens: 0, inputTokens: 0, cacheHitPct: 0, rateTokS: 8 },
1532
+ "统计合并保留有效的零值,仅对缺失字段回退(R-01-009/AC-13)",
1533
+ );
1505
1534
  assert.deepEqual(
1506
1535
  usageSummary({ uncachedInputTokens: 100, cacheReadTokens: 700, cacheWriteTokens: 200 }),
1507
1536
  { inputTokens: 1_000, cacheHitPct: 70 },
@@ -2815,13 +2844,13 @@ assert.ok(
2815
2844
  bundle.includes("list.insertBefore(rec.el, ref)"),
2816
2845
  "卡片仅在顺序/归属变化时移动 DOM(insertBefore 位置守卫)",
2817
2846
  );
2818
- // ---- R-01-009/AC-02、R-01-009/AC-05、R-01-012/AC-01..04、R-01-013/AC-01..06 ----
2847
+ // ---- R-01-009/AC-02、R-01-009/AC-05、R-01-012/AC-01..04、R-01-013/AC-01..06、AC-12 ----
2819
2848
  assert.ok(bundle.includes("foldedConversationTimeline"), "活动卡时间线由折叠分组唯一来源派生(R-01-012、R-01-017)");
2820
2849
  assert.ok(!bundle.includes("dap-trace-time"), "工作项时间线不渲染行级耗时元素,对齐主会话窗口(R-01-009/AC-07、C-012)");
2821
2850
  assert.ok(!bundle.includes("PROGRESS_THINK_BASE") && !bundle.includes("progressFloor"), "回合进度纯时间驱动,无思考基线/单调下限残留(R-01-009/AC-06、C-014)");
2822
2851
  assert.ok(bundle.includes("progressHalfLifeSec"), "bundle 含半衰期速率校准函数(R-01-009/AC-06、C-025、C-044)");
2823
2852
  assert.ok(
2824
- bundle.includes("halfLifeSec: progressHalfLifeSec({ rateTokS })"),
2853
+ bundle.includes("halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS })"),
2825
2854
  "进度赋值每帧按最新实测速率现算半衰期(R-01-009/AC-06、C-044)",
2826
2855
  );
2827
2856
  assert.ok(
@@ -2841,9 +2870,18 @@ assert.ok(
2841
2870
  "统计行双段结构:左列文本 + 右置时长(R-01-009/AC-05)",
2842
2871
  );
2843
2872
  assert.ok(bundle.includes("function renderTokenStats"), "运行卡统计行继续复用既有渲染逻辑(R-01-009/AC-05)");
2873
+ // R-01-009/AC-13 等待卡保留进入等待前最后已知的 token 统计
2874
+ assert.ok(
2875
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), statsRow, foot];'),
2876
+ "等待卡骨架在时间线后保留统计行(R-01-009/AC-13)",
2877
+ );
2844
2878
  assert.ok(
2845
- bundle.includes("function renderAwaitingDuration") && bundle.includes("dap-await-head"),
2846
- "等待卡耗时与等待类型胶囊共用同行右置渲染(R-01-009/AC-12)",
2879
+ bundle.includes("renderTokenStats(el, entry);") && bundle.includes("removeAwaitingHeadDuration(el);"),
2880
+ "等待卡复用统计行并清理胶囊同行的旧耗时节点(R-01-009/AC-12、AC-13)",
2881
+ );
2882
+ assert.ok(
2883
+ bundle.includes("function removeAwaitingHeadDuration") && bundle.includes("awaitHead.append(capsule);"),
2884
+ "等待类型胶囊独立成行,固定耗时回到统计行(R-01-009/AC-12)",
2847
2885
  );
2848
2886
  assert.ok(bundle.includes("lastTurnDuration({"), "等待卡耗时由最近完整回合边界派生(R-01-009/AC-12)");
2849
2887
  assert.ok(bundle.includes("`输入 ${fmtTokens("), "统计行含输入/输出中文短标签(R-01-009/AC-05)");
@@ -2858,6 +2896,14 @@ assert.ok(
2858
2896
  );
2859
2897
  assert.ok(!bundle.includes("≈"), "速率不再携带约等于符号(R-01-009/AC-05)");
2860
2898
  assert.ok(bundle.includes("dap-history-line"), "历史卡包含用户/agent 两条消息预览行");
2899
+ // R-01-013/AC-12 最近历史卡保留最近回合统计
2900
+ assert.ok(
2901
+ bundle.includes('return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];'),
2902
+ "历史卡统计行位于助手预览后、活动时间前",
2903
+ );
2904
+ assert.ok(bundle.includes("statsFromProjection"), "历史卡统计复用列表投影派生函数(R-01-013/AC-12)");
2905
+ assert.ok(bundle.includes("lastRuntimeStats"), "会话结束时保留最后已知运行统计(R-01-013/AC-12)");
2906
+ assert.ok(bundle.includes("recentDurationFallbackIds"), "历史卡缺回合边界时复用 history 补读(R-01-013/AC-12)");
2861
2907
  // R-01-003/AC-04
2862
2908
  assert.ok(bundle.includes("parentId: m.isSub ? String(parentId) : null"), "活动卡条目保留直属母会话 id");
2863
2909
  assert.ok(bundle.includes("function trackRuns(entries)"), "母会话轨道拓扑由纯函数 trackRuns 一次求出");
@@ -2922,9 +2968,9 @@ assert.ok(
2922
2968
  assert.ok(bundle.includes("function activeSessionIds(byId = {})"), "活动子代理沿 parentId 链补齐活动祖先");
2923
2969
  // ---- R-01-016/AC-01 等待卡保留最近工作项时间线 ----
2924
2970
  assert.ok(
2925
- bundle.includes('awaitHead.append(capsule, makeEl("span", "dap-token-time"))') &&
2926
- bundle.includes('return [head, row, makeEl("div", "dap-trace"), foot];'),
2927
- "awaiting 骨架在标题行与末行两段(胶囊+正文)之间含时间线,并将耗时放在胶囊同行右侧(R-01-016/AC-01、R-01-009/AC-12,C-043)",
2971
+ bundle.includes("awaitHead.append(capsule);") &&
2972
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), statsRow, foot];'),
2973
+ "awaiting 骨架在标题行与统计行、末行两段(胶囊+正文)之间含时间线,固定耗时由统计行承载(R-01-016/AC-01、R-01-009/AC-12,C-043)",
2928
2974
  );
2929
2975
  // ---- R-01-002/AC-10 完成提醒卡「移入历史」按钮 ----
2930
2976
  assert.ok(
package/src/client.mjs CHANGED
@@ -521,14 +521,10 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
521
521
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
522
522
  min-width: 0; margin: 2px 0 0;
523
523
  }
524
- /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
524
+ /* 等待类型胶囊独立同行:耗时回到统计行最右侧,不参与等待脉冲。 */
525
525
  [data-dsh-activity-pane] .dap-await-head {
526
526
  display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
527
527
  }
528
- [data-dsh-activity-pane] .dap-await-head .dap-token-time {
529
- margin-left: auto; flex: none; font-size: 10px; line-height: 14px;
530
- color: #8f9aaa; font-variant-numeric: tabular-nums;
531
- }
532
528
  [data-dsh-activity-pane] .dap-note-row {
533
529
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
534
530
  }
@@ -547,7 +543,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
547
543
  color: color-mix(in srgb, currentColor 82%, transparent);
548
544
  background: color-mix(in srgb, currentColor 14%, transparent);
549
545
  }
550
- /* 运行卡富化(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
546
+ /* 运行卡与最近卡统计(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
551
547
  [data-dsh-activity-pane] .dap-progress {
552
548
  display: flex; align-items: center; gap: 7px; min-width: 0;
553
549
  }
@@ -1086,7 +1082,7 @@ function apply(ctx) {
1086
1082
  const livenessById = new Map();
1087
1083
  /** 委托周期进度锚点记账:id → progressAnchor 状态(R-01-009/AC-06)。 */
1088
1084
  const progressAnchorById = new Map();
1089
- /** 订阅停止后保留最近快照,供 awaiting/recent 卡继续显示上下文。 */
1085
+ /** 订阅停止后保留最近快照与最后已知运行统计,供 awaiting/recent 卡继续显示上下文。 */
1090
1086
  const sessionDetailsById = new Map();
1091
1087
  /** 会话跳转的单一重试链;避免重复点击叠加 refresh/timer。 */
1092
1088
  const openRetryStates = new Map();
@@ -1761,7 +1757,10 @@ function apply(ctx) {
1761
1757
  const agentLabel = makeEl("span", "dap-history-label");
1762
1758
  agentLabel.textContent = "助手";
1763
1759
  agentLine.append(agentIcon, agentLabel, makeEl("span", "dap-history-separator"), makeEl("span", "dap-history-text"));
1764
- return [head, row, userLine, agentLine, makeEl("div", "dap-note")];
1760
+ const statsRow = makeEl("div", "dap-token-stats");
1761
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1762
+ statsRow.hidden = true;
1763
+ return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];
1765
1764
  }
1766
1765
  if (kind === "awaiting") {
1767
1766
  const row = makeEl("div", "dap-row");
@@ -1775,10 +1774,13 @@ function apply(ctx) {
1775
1774
  const noteRow = makeEl("div", "dap-note-row");
1776
1775
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
1777
1776
  const awaitHead = makeEl("div", "dap-await-head");
1778
- awaitHead.append(capsule, makeEl("span", "dap-token-time"));
1777
+ awaitHead.append(capsule);
1778
+ const statsRow = makeEl("div", "dap-token-stats");
1779
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1780
+ statsRow.hidden = true;
1779
1781
  const foot = makeEl("div", "dap-foot");
1780
1782
  foot.append(awaitHead, noteRow);
1781
- return [head, row, makeEl("div", "dap-trace"), foot];
1783
+ return [head, row, makeEl("div", "dap-trace"), statsRow, foot];
1782
1784
  }
1783
1785
  const row = makeEl("div", "dap-row");
1784
1786
  row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
@@ -2139,14 +2141,19 @@ function apply(ctx) {
2139
2141
  }
2140
2142
  }
2141
2143
 
2142
- /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2144
+ /** 统计行渲染:运行卡、等待卡与最近卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2143
2145
  function renderTokenStats(el, entry) {
2144
2146
  let stats = el.querySelector(".dap-token-stats");
2145
2147
  if (stats === null) {
2146
2148
  stats = makeEl("div", "dap-token-stats");
2147
2149
  stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
2148
2150
  stats.hidden = true;
2149
- el.append(stats);
2151
+ const foot = entry.kind === "awaiting" ? el.querySelector(".dap-foot") : null;
2152
+ if (foot !== null) el.insertBefore(stats, foot);
2153
+ else el.append(stats);
2154
+ } else if (entry.kind === "awaiting") {
2155
+ const foot = el.querySelector(".dap-foot");
2156
+ if (foot !== null && stats.nextElementSibling !== foot) el.insertBefore(stats, foot);
2150
2157
  }
2151
2158
  let mainTextEl = stats.querySelector(".dap-token-main");
2152
2159
  let timeEl = stats.querySelector(".dap-token-time");
@@ -2168,25 +2175,9 @@ function apply(ctx) {
2168
2175
  if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
2169
2176
  }
2170
2177
 
2171
- /** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
2172
- function renderAwaitingDuration(el, entry) {
2173
- const foot = el.querySelector(".dap-foot");
2174
- if (foot === null) return;
2175
- let head = foot.querySelector(".dap-await-head");
2176
- if (head === null) {
2177
- const capsule = foot.querySelector(".dap-capsule");
2178
- if (capsule === null) return;
2179
- head = makeEl("div", "dap-await-head");
2180
- capsule.replaceWith(head);
2181
- head.append(capsule);
2182
- }
2183
- let time = head.querySelector(".dap-token-time");
2184
- if (time === null) {
2185
- time = makeEl("span", "dap-token-time");
2186
- head.append(time);
2187
- }
2188
- const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
2189
- if (time.textContent !== text) time.textContent = text;
2178
+ /** 旧热装等待骨架清理:耗时已迁回统计行,去掉胶囊同行残留的时长节点。 */
2179
+ function removeAwaitingHeadDuration(el) {
2180
+ el.querySelector(".dap-await-head .dap-token-time")?.remove();
2190
2181
  }
2191
2182
 
2192
2183
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
@@ -2321,12 +2312,14 @@ function apply(ctx) {
2321
2312
  restoreTextField(text, previews[i]);
2322
2313
  }
2323
2314
  }
2315
+ renderTokenStats(el, entry);
2324
2316
  }
2325
2317
 
2326
2318
  if (entry.kind === "awaiting") {
2327
2319
  const traceContainer = el.querySelector(".dap-trace");
2328
2320
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
2329
- renderAwaitingDuration(el, entry);
2321
+ removeAwaitingHeadDuration(el);
2322
+ renderTokenStats(el, entry);
2330
2323
  const confirm = el.querySelector(".dap-confirm");
2331
2324
  if (confirm !== null) {
2332
2325
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -2971,11 +2964,11 @@ function apply(ctx) {
2971
2964
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
2972
2965
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
2973
2966
  const projection = snapshot?.byId?.[entry.id]?.projectionValues;
2974
- const stats = projection?.sessionStats;
2975
- const rateTokS =
2976
- stats && Number.isFinite(stats.decodeMs) && stats.decodeMs > 0
2977
- ? stats.decodeTokens / (stats.decodeMs / 1000)
2978
- : null;
2967
+ const projectionStats = statsFromProjection(projection);
2968
+ if (entry.kind === "awaiting") {
2969
+ // 等待期间冻结运行停止前的最后已知统计;刷新/无留存时回退当前列表投影。
2970
+ Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
2971
+ }
2979
2972
  // 委托周期锚点(R-01-009/AC-06):全部活动条目逐帧记账——锚点不因呈现翻转
2980
2973
  // (委托期与 awaiting 互转)或瞬时不可见而丢失,仅 dispose 时整体清除;周期内
2981
2974
  // 进度连续(含 settle 处理回合),周期外由宿主回合起点驱动(新回合归零)。
@@ -2991,13 +2984,15 @@ function apply(ctx) {
2991
2984
  progressAnchorById.set(entry.id, anchor);
2992
2985
  if (entry.kind === "running") {
2993
2986
  const elapsedMs = Number.isFinite(anchor.anchor) ? Math.max(0, now - anchor.anchor) : null;
2994
- const outputTokens = projection?.tokenUsage?.outputTokens ?? null;
2995
- Object.assign(entry, runtimeStats({ elapsedMs, outputTokens, rateTokS }));
2996
- Object.assign(entry, usageSummary(projection?.tokenUsage ?? {}));
2987
+ Object.assign(entry, projectionStats, { elapsedMs });
2988
+ if (detail) {
2989
+ // 列表投影短暂缺字段时,不覆盖此前已知的有效统计。
2990
+ detail.lastRuntimeStats = mergeRuntimeStats(projectionStats, detail.lastRuntimeStats);
2991
+ }
2997
2992
  // 回合进度:纯时间驱动 y = t/(t+k),半衰期每帧按最新实测速率现算,固定
2998
2993
  // k 下单调性由函数本身保证;k 变化时允许进度随速率回落而回退(委托周期
2999
2994
  // 连续、周期外回合切换归零,R-01-009/AC-06,C-014、C-044)。
3000
- entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS }) });
2995
+ entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
3001
2996
  }
3002
2997
  }
3003
2998
  // 历史区时间精化(R-01-010/AC-08、AC-09):从保留快照的 turnTimings 与已拉取的
@@ -3018,6 +3013,23 @@ function apply(ctx) {
3018
3013
  recentTotal = recentCandidates.length;
3019
3014
  const recent = recentCandidates.slice(0, recentVisibleCount);
3020
3015
  recentHasMore = recent.length < recentTotal;
3016
+ // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
3017
+ // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
3018
+ const recentDurationFallbackIds = new Set();
3019
+ for (const entry of recent) {
3020
+ const detail = sessionDetailsById.get(entry.id);
3021
+ const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
3022
+ const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
3023
+ const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
3024
+ const retained = detail?.lastRuntimeStats;
3025
+ Object.assign(entry, stats, retained ? {
3026
+ outputTokens: retained.outputTokens ?? stats.outputTokens,
3027
+ inputTokens: retained.inputTokens ?? stats.inputTokens,
3028
+ cacheHitPct: retained.cacheHitPct ?? stats.cacheHitPct,
3029
+ rateTokS: retained.rateTokS ?? stats.rateTokS,
3030
+ } : null);
3031
+ if (elapsedMs === null) recentDurationFallbackIds.add(entry.id);
3032
+ }
3021
3033
  syncRecentTimeClock(recent.length > 0);
3022
3034
  // 预览只对当前显示的 recent 卡计算(活动卡不显示预览);快照/历史引用不变时命中缓存。
3023
3035
  // 完成瞬间的窗口快照可能先有用户消息、后到 agent reply;缺任一预览时补读一次 history。
@@ -3038,7 +3050,10 @@ function apply(ctx) {
3038
3050
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
3039
3051
  }
3040
3052
  // 补充数据读取优先级:当前会话最优先,活动区先于当前已显示历史页(区内按显示顺序)。
3041
- const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
3053
+ const durationFallbackIds = new Set([
3054
+ ...active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id),
3055
+ ...recentDurationFallbackIds,
3056
+ ]);
3042
3057
  const detailIds = [...active, ...recent].map((entry) => entry.id);
3043
3058
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
3044
3059
  loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
package/src/core.mjs CHANGED
@@ -1067,6 +1067,31 @@ export function usageSummary({ uncachedInputTokens = null, cacheReadTokens = nul
1067
1067
  return { inputTokens, cacheHitPct };
1068
1068
  }
1069
1069
 
1070
+ /** 从 sessions.list 的投影统一归一卡片统计:运行卡与最近卡共享同一口径。 */
1071
+ export function statsFromProjection(projection = null, elapsedMs = null) {
1072
+ const usage = isRecord(projection?.tokenUsage) ? projection.tokenUsage : {};
1073
+ const sessionStats = isRecord(projection?.sessionStats) ? projection.sessionStats : {};
1074
+ const rateTokS = Number.isFinite(sessionStats.decodeMs) && sessionStats.decodeMs > 0
1075
+ ? sessionStats.decodeTokens / (sessionStats.decodeMs / 1000)
1076
+ : null;
1077
+ return {
1078
+ ...runtimeStats({ elapsedMs, outputTokens: usage.outputTokens, rateTokS }),
1079
+ ...usageSummary(usage),
1080
+ };
1081
+ }
1082
+
1083
+ /** 统计字段按字段合并:当前值有效时优先,否则回退到上一份已知值;不合并 elapsedMs。 */
1084
+ export function mergeRuntimeStats(current = null, fallback = null) {
1085
+ const currentStats = isRecord(current) ? current : {};
1086
+ const fallbackStats = isRecord(fallback) ? fallback : {};
1087
+ return {
1088
+ outputTokens: currentStats.outputTokens ?? fallbackStats.outputTokens ?? null,
1089
+ inputTokens: currentStats.inputTokens ?? fallbackStats.inputTokens ?? null,
1090
+ cacheHitPct: currentStats.cacheHitPct ?? fallbackStats.cacheHitPct ?? null,
1091
+ rateTokS: currentStats.rateTokS ?? fallbackStats.rateTokS ?? null,
1092
+ };
1093
+ }
1094
+
1070
1095
  /** 需要用户行动的种类的展示文案。 */
1071
1096
  export function pendingText(kind) {
1072
1097
  return PENDING_LABELS[kind] ?? PENDING_UNKNOWN_LABEL;
@@ -1168,8 +1193,8 @@ export function escapeCssString(value) {
1168
1193
  * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1169
1194
  * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1170
1195
  * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1171
- * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡
1172
- * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12)。 */
1196
+ * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04);durationFallbackNeeded 表示等待卡或最近卡
1197
+ * 需要在已加载的旧 history 之后再取一次最新回合边界(R-01-009/AC-12、R-01-013/AC-12)。 */
1173
1198
  export function detailLoadPlan({
1174
1199
  detail = {},
1175
1200
  isSubagent = false,