dsh-activity-pane 0.3.0 → 0.6.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.
package/src/client.mjs CHANGED
@@ -467,23 +467,41 @@ const CSS = `
467
467
  /* 复审修复(C-040、C-043):状态点光晕三类同强度、仅换色相——阻塞金、完成绿与错误红光晕一致。 */
468
468
  /* 工作区徽标「图标+文本」双段:文件夹图标与左边栏工作区条目同源(R-01-003/AC-06);
469
469
  名称字号不低于 10.5px(AC-07),行高保持 14px 以维持胶囊与卡片高度。
470
- 着色(AC-08~AC-11):核心映射提供 OKLCH hue;深色主题文字取高明度中高彩度,
471
- 浅色主题文字取低明度中高彩度。文字直接使用调色板色,底色与描边在 OKLCH
472
- 空间按透明度混合;前景与背景始终同色相、明度对比拉开。 */
470
+ 着色(AC-08~AC-12):核心映射提供 12 个前景身份槽与每槽 3 个背景变体;
471
+ 深色/浅色主题分别使用前景与背景槽位的 L/C 参数,文字使用前景调色板色,
472
+ 底色使用独立背景源色、描边使用前景调色板色,分别在 OKLCH 空间按变体混合强度呈现;前景与背景保持同色相族。 */
473
473
  [data-dsh-activity-pane] .dap-workspace {
474
474
  width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 3px;
475
475
  overflow: hidden;
476
476
  font-size: 10.5px; line-height: 14px;
477
- --dap-workspace-color: oklch(0.78 0.16 var(--dap-workspace-hue, 235));
477
+ --dap-workspace-color: oklch(
478
+ var(--dap-workspace-dark-l, 0.78)
479
+ var(--dap-workspace-dark-c, 0.16)
480
+ var(--dap-workspace-hue, 235)
481
+ );
482
+ --dap-workspace-background-color: oklch(
483
+ var(--dap-workspace-bg-dark-l, 0.38)
484
+ var(--dap-workspace-bg-dark-c, 0.06)
485
+ var(--dap-workspace-hue, 235)
486
+ );
478
487
  color: var(--dap-workspace-color);
479
- background: color-mix(in oklch, var(--dap-workspace-color) 14%, transparent);
480
- border: 1px solid color-mix(in oklch, var(--dap-workspace-color) 34%, transparent);
488
+ background: color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-dark-mix, 31%), transparent);
489
+ border: 1px solid color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-dark-border-mix, 52%), transparent);
481
490
  border-radius: 999px; padding: 0 7px;
482
491
  }
483
492
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
484
- --dap-workspace-color: oklch(0.48 0.15 var(--dap-workspace-hue, 235));
485
- background: color-mix(in oklch, var(--dap-workspace-color) 10%, transparent);
486
- border-color: color-mix(in oklch, var(--dap-workspace-color) 28%, transparent);
493
+ --dap-workspace-color: oklch(
494
+ var(--dap-workspace-light-l, 0.48)
495
+ var(--dap-workspace-light-c, 0.15)
496
+ var(--dap-workspace-hue, 235)
497
+ );
498
+ --dap-workspace-background-color: oklch(
499
+ var(--dap-workspace-bg-light-l, 0.84)
500
+ var(--dap-workspace-bg-light-c, 0.06)
501
+ var(--dap-workspace-hue, 235)
502
+ );
503
+ background: color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-light-mix, 27%), transparent);
504
+ border-color: color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-light-border-mix, 42%), transparent);
487
505
  }
488
506
  [data-dsh-activity-pane] .dap-workspace-icon { flex: none; display: inline-flex; }
489
507
  [data-dsh-activity-pane] .dap-workspace-icon svg { display: block; }
@@ -521,14 +539,10 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
521
539
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
522
540
  min-width: 0; margin: 2px 0 0;
523
541
  }
524
- /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
542
+ /* 等待类型胶囊独立同行:耗时回到统计行最右侧,不参与等待脉冲。 */
525
543
  [data-dsh-activity-pane] .dap-await-head {
526
544
  display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
527
545
  }
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
546
  [data-dsh-activity-pane] .dap-note-row {
533
547
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
534
548
  }
@@ -547,7 +561,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
547
561
  color: color-mix(in srgb, currentColor 82%, transparent);
548
562
  background: color-mix(in srgb, currentColor 14%, transparent);
549
563
  }
550
- /* 运行卡富化(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
564
+ /* 运行卡与最近卡统计(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
551
565
  [data-dsh-activity-pane] .dap-progress {
552
566
  display: flex; align-items: center; gap: 7px; min-width: 0;
553
567
  }
@@ -1030,16 +1044,18 @@ function apply(ctx) {
1030
1044
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
1031
1045
  let pulseSignature = "";
1032
1046
  // E2E-only deterministic seams(T-089/T-090):显式 URL fragment 最多把本页首次列表 ready 呈现
1033
- // 或正式 models RPC 延后 1s;默认路径为 0,不伪造服务响应、快照或生产时序。
1047
+ // 或正式 models RPC 延后 1s;列表延迟从首个窗格 render 开始计时,避免宿主启动耗时
1048
+ // 把 pending 观察窗口提前消耗;默认路径为 0,不伪造服务响应、快照或生产时序。
1034
1049
  const e2eParams = new URLSearchParams(window.location.hash.slice(1));
1035
1050
  const requestedListDelay = Number(e2eParams.get("dap-e2e-list-delay"));
1036
- const e2eListReadyAt = Number.isFinite(requestedListDelay) && requestedListDelay > 0
1037
- ? Date.now() + Math.min(requestedListDelay, 1_000)
1051
+ const e2eListDelayMs = Number.isFinite(requestedListDelay) && requestedListDelay > 0
1052
+ ? Math.min(requestedListDelay, 1_000)
1038
1053
  : 0;
1039
1054
  const requestedModelDelay = Number(e2eParams.get("dap-e2e-model-delay"));
1040
1055
  const e2eModelDelayMs = Number.isFinite(requestedModelDelay) && requestedModelDelay > 0
1041
1056
  ? Math.min(requestedModelDelay, 1_000)
1042
1057
  : 0;
1058
+ let e2eListReadyAt = 0;
1043
1059
  let e2eListReleaseTimer = null;
1044
1060
  const e2eModelDelayWaiters = new Map();
1045
1061
  function delayedModelCall(call) {
@@ -1060,6 +1076,9 @@ function apply(ctx) {
1060
1076
  let paneWidth = readStoredPaneWidth();
1061
1077
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
1062
1078
  let lastActivatedId = null;
1079
+ /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
1080
+ let autoScrolledCurrentId = null;
1081
+ let autoScrolledCurrentCard = null;
1063
1082
  /** 完成确认状态(R-01-002/AC-03、AC-10~AC-12):id → { lastTurnEnd, ackedAt },
1064
1083
  * 来自宿主侧 ack 通道(SSE 全量快照),随每次推送整体替换。 */
1065
1084
  const completeAcksById = new Map();
@@ -1086,7 +1105,7 @@ function apply(ctx) {
1086
1105
  const livenessById = new Map();
1087
1106
  /** 委托周期进度锚点记账:id → progressAnchor 状态(R-01-009/AC-06)。 */
1088
1107
  const progressAnchorById = new Map();
1089
- /** 订阅停止后保留最近快照,供 awaiting/recent 卡继续显示上下文。 */
1108
+ /** 订阅停止后保留最近快照与最后已知运行统计,供 awaiting/recent 卡继续显示上下文。 */
1090
1109
  const sessionDetailsById = new Map();
1091
1110
  /** 会话跳转的单一重试链;避免重复点击叠加 refresh/timer。 */
1092
1111
  const openRetryStates = new Map();
@@ -1761,7 +1780,10 @@ function apply(ctx) {
1761
1780
  const agentLabel = makeEl("span", "dap-history-label");
1762
1781
  agentLabel.textContent = "助手";
1763
1782
  agentLine.append(agentIcon, agentLabel, makeEl("span", "dap-history-separator"), makeEl("span", "dap-history-text"));
1764
- return [head, row, userLine, agentLine, makeEl("div", "dap-note")];
1783
+ const statsRow = makeEl("div", "dap-token-stats");
1784
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1785
+ statsRow.hidden = true;
1786
+ return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];
1765
1787
  }
1766
1788
  if (kind === "awaiting") {
1767
1789
  const row = makeEl("div", "dap-row");
@@ -1775,10 +1797,13 @@ function apply(ctx) {
1775
1797
  const noteRow = makeEl("div", "dap-note-row");
1776
1798
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
1777
1799
  const awaitHead = makeEl("div", "dap-await-head");
1778
- awaitHead.append(capsule, makeEl("span", "dap-token-time"));
1800
+ awaitHead.append(capsule);
1801
+ const statsRow = makeEl("div", "dap-token-stats");
1802
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1803
+ statsRow.hidden = true;
1779
1804
  const foot = makeEl("div", "dap-foot");
1780
1805
  foot.append(awaitHead, noteRow);
1781
- return [head, row, makeEl("div", "dap-trace"), foot];
1806
+ return [head, row, makeEl("div", "dap-trace"), statsRow, foot];
1782
1807
  }
1783
1808
  const row = makeEl("div", "dap-row");
1784
1809
  row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
@@ -2139,14 +2164,19 @@ function apply(ctx) {
2139
2164
  }
2140
2165
  }
2141
2166
 
2142
- /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2167
+ /** 统计行渲染:运行卡、等待卡与最近卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2143
2168
  function renderTokenStats(el, entry) {
2144
2169
  let stats = el.querySelector(".dap-token-stats");
2145
2170
  if (stats === null) {
2146
2171
  stats = makeEl("div", "dap-token-stats");
2147
2172
  stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
2148
2173
  stats.hidden = true;
2149
- el.append(stats);
2174
+ const foot = entry.kind === "awaiting" ? el.querySelector(".dap-foot") : null;
2175
+ if (foot !== null) el.insertBefore(stats, foot);
2176
+ else el.append(stats);
2177
+ } else if (entry.kind === "awaiting") {
2178
+ const foot = el.querySelector(".dap-foot");
2179
+ if (foot !== null && stats.nextElementSibling !== foot) el.insertBefore(stats, foot);
2150
2180
  }
2151
2181
  let mainTextEl = stats.querySelector(".dap-token-main");
2152
2182
  let timeEl = stats.querySelector(".dap-token-time");
@@ -2168,25 +2198,9 @@ function apply(ctx) {
2168
2198
  if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
2169
2199
  }
2170
2200
 
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;
2201
+ /** 旧热装等待骨架清理:耗时已迁回统计行,去掉胶囊同行残留的时长节点。 */
2202
+ function removeAwaitingHeadDuration(el) {
2203
+ el.querySelector(".dap-await-head .dap-token-time")?.remove();
2190
2204
  }
2191
2205
 
2192
2206
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
@@ -2213,22 +2227,40 @@ function apply(ctx) {
2213
2227
  renderTrace(container, entry.timeline, { lastOnly });
2214
2228
  }
2215
2229
 
2216
- function renderCardInto(el, entry, hueByWorkspace) {
2230
+ function renderCardInto(el, entry, colorByWorkspace) {
2217
2231
  const workspaceLabel = el.querySelector(".dap-workspace");
2218
2232
  if (workspaceLabel !== null) {
2219
2233
  const workspaceText = workspaceLabel.querySelector(".dap-workspace-text");
2220
- if (entry.workspaceTitle !== "") {
2234
+ const color = colorByWorkspace.get(entry.workspaceKey);
2235
+ const foreground = color?.foreground;
2236
+ const background = color?.background;
2237
+ const colorVariables = {
2238
+ "--dap-workspace-hue": foreground?.hue,
2239
+ "--dap-workspace-dark-l": foreground?.darkL,
2240
+ "--dap-workspace-dark-c": foreground?.darkC,
2241
+ "--dap-workspace-light-l": foreground?.lightL,
2242
+ "--dap-workspace-light-c": foreground?.lightC,
2243
+ "--dap-workspace-bg-dark-l": background?.dark?.l,
2244
+ "--dap-workspace-bg-dark-c": background?.dark?.c,
2245
+ "--dap-workspace-bg-dark-mix": background?.dark?.mix,
2246
+ "--dap-workspace-bg-dark-border-mix": background?.dark?.borderMix,
2247
+ "--dap-workspace-bg-light-l": background?.light?.l,
2248
+ "--dap-workspace-bg-light-c": background?.light?.c,
2249
+ "--dap-workspace-bg-light-mix": background?.light?.mix,
2250
+ "--dap-workspace-bg-light-border-mix": background?.light?.borderMix,
2251
+ };
2252
+ if (entry.workspaceTitle !== "" && foreground !== undefined && background !== undefined) {
2221
2253
  if (workspaceText !== null) restoreTextField(workspaceText, entry.workspaceTitle);
2222
- const hue = hueByWorkspace.get(entry.workspaceKey) ?? workspaceHue(entry.workspaceKey);
2223
- const hueText = hue === null ? "" : String(hue);
2224
- if (workspaceLabel.style.getPropertyValue("--dap-workspace-hue") !== hueText) {
2225
- if (hue === null) workspaceLabel.style.removeProperty("--dap-workspace-hue");
2226
- else workspaceLabel.style.setProperty("--dap-workspace-hue", hueText);
2254
+ for (const [property, value] of Object.entries(colorVariables)) {
2255
+ const next = value === undefined ? "" : String(value);
2256
+ if (workspaceLabel.style.getPropertyValue(property) === next) continue;
2257
+ if (next === "") workspaceLabel.style.removeProperty(property);
2258
+ else workspaceLabel.style.setProperty(property, next);
2227
2259
  }
2228
2260
  workspaceLabel.removeAttribute("hidden");
2229
2261
  } else {
2230
2262
  if (workspaceText !== null) restoreTextField(workspaceText, "");
2231
- workspaceLabel.style.removeProperty("--dap-workspace-hue");
2263
+ for (const property of Object.keys(colorVariables)) workspaceLabel.style.removeProperty(property);
2232
2264
  workspaceLabel.setAttribute("hidden", "");
2233
2265
  }
2234
2266
  }
@@ -2321,12 +2353,14 @@ function apply(ctx) {
2321
2353
  restoreTextField(text, previews[i]);
2322
2354
  }
2323
2355
  }
2356
+ renderTokenStats(el, entry);
2324
2357
  }
2325
2358
 
2326
2359
  if (entry.kind === "awaiting") {
2327
2360
  const traceContainer = el.querySelector(".dap-trace");
2328
2361
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
2329
- renderAwaitingDuration(el, entry);
2362
+ removeAwaitingHeadDuration(el);
2363
+ renderTokenStats(el, entry);
2330
2364
  const confirm = el.querySelector(".dap-confirm");
2331
2365
  if (confirm !== null) {
2332
2366
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -2605,7 +2639,7 @@ function apply(ctx) {
2605
2639
 
2606
2640
  /** 渲染某一张卡片进指定列表容器(活动/历史通用)。index 是条目在卡片序列中的
2607
2641
  * 序号,offset 是容器内首个卡片前的非卡片子节点数(活动区有轨道层、历史区有段头)。 */
2608
- function renderCardIntoList(list, entry, reuseMap, index, offset, hueByWorkspace) {
2642
+ function renderCardIntoList(list, entry, reuseMap, index, offset, colorByWorkspace) {
2609
2643
  let rec = reuseMap.get(entry.id);
2610
2644
  if (rec === undefined) {
2611
2645
  const el = document.createElement("div");
@@ -2662,7 +2696,7 @@ function apply(ctx) {
2662
2696
  recentTimeText ? "," + recentTimeText : ""
2663
2697
  }`,
2664
2698
  );
2665
- renderCardInto(rec.el, entry, hueByWorkspace);
2699
+ renderCardInto(rec.el, entry, colorByWorkspace);
2666
2700
  // 只有顺序/归属真正变化时才移动 DOM:每次渲染无条件 appendChild 会把所有
2667
2701
  // 卡片瞬时移除再插回——按下/抬起之间经过的移动让浏览器取消 click;焦点卡
2668
2702
  // 被瞬时断开而失焦;悬停卡的 :hover 也随之丢失且不再补发。会话活跃期间
@@ -2672,6 +2706,37 @@ function apply(ctx) {
2672
2706
  return true;
2673
2707
  }
2674
2708
 
2709
+ /** 原生侧栏切换当前会话后,只把已呈现且未完整可见的当前卡片滚入窗格;降低动效偏好时不平滑。 */
2710
+ function ensureCurrentCardVisible(scroll, currentId) {
2711
+ const id = currentId === null || currentId === undefined ? null : String(currentId);
2712
+ if (id === null || id === "") {
2713
+ autoScrolledCurrentId = null;
2714
+ autoScrolledCurrentCard = null;
2715
+ return;
2716
+ }
2717
+ // 当前会话卡片只有在活动列表中才参与自动定位;完成确认后同一 current
2718
+ // 会话会进入历史区,不能因仍带 data-current 把窗格滚到历史卡片。
2719
+ const card = scroll?.querySelector?.(`.${LIST_CLASS} .${CARD_CLASS}[data-current]`) ?? null;
2720
+ if (
2721
+ card === null ||
2722
+ card.dataset.sessionId !== id ||
2723
+ scroll.clientHeight <= 0 ||
2724
+ card.getBoundingClientRect().height <= 0
2725
+ )
2726
+ return;
2727
+ if (autoScrolledCurrentId === id && autoScrolledCurrentCard === card) return;
2728
+ if (scrollCardIntoView(scroll, card, prefersReducedMotion() ? "auto" : "smooth")) {
2729
+ autoScrolledCurrentId = id;
2730
+ autoScrolledCurrentCard = card;
2731
+ return;
2732
+ }
2733
+ const viewport = scroll.getBoundingClientRect();
2734
+ const rect = card.getBoundingClientRect();
2735
+ if (rect.height <= viewport.height && (rect.top < viewport.top || rect.bottom > viewport.bottom)) return;
2736
+ autoScrolledCurrentId = id;
2737
+ autoScrolledCurrentCard = card;
2738
+ }
2739
+
2675
2740
  function pruneCards(reuseMap, alive) {
2676
2741
  for (const [id, rec] of reuseMap) {
2677
2742
  if (alive.has(id)) continue;
@@ -2855,6 +2920,8 @@ function apply(ctx) {
2855
2920
  recentTotal = 0;
2856
2921
  recentHasMore = false;
2857
2922
  recentAppendQueued = false;
2923
+ autoScrolledCurrentId = null;
2924
+ autoScrolledCurrentCard = null;
2858
2925
  // 旧窗格已脱离文档:其在飞平移的 transitionend 不再触发,逐元素取消避免残留。
2859
2926
  for (const el of [...shiftCleanups.keys()]) cancelShift(el);
2860
2927
  }
@@ -2863,6 +2930,7 @@ function apply(ctx) {
2863
2930
  const recentSection = pane.querySelector(`.${RECENT_CLASS}`);
2864
2931
  if (activeList === null || recentSection === null) return;
2865
2932
 
2933
+ if (e2eListReadyAt === 0 && e2eListDelayMs > 0) e2eListReadyAt = Date.now() + e2eListDelayMs;
2866
2934
  let snapshot = getSnapshot(sessions, "list");
2867
2935
  if (e2eListReadyAt > Date.now() && snapshot?.phase !== "error") {
2868
2936
  snapshot = { phase: "pending" };
@@ -2887,7 +2955,7 @@ function apply(ctx) {
2887
2955
  for (const [id, state] of progressAnchorById) {
2888
2956
  if (delegationActive(state, now)) delegatingIds.add(id);
2889
2957
  }
2890
- const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds);
2958
+ const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds);
2891
2959
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
2892
2960
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
2893
2961
  const runLikeIds = new Set(
@@ -2971,11 +3039,11 @@ function apply(ctx) {
2971
3039
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
2972
3040
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
2973
3041
  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;
3042
+ const projectionStats = statsFromProjection(projection);
3043
+ if (entry.kind === "awaiting") {
3044
+ // 等待期间冻结运行停止前的最后已知统计;刷新/无留存时回退当前列表投影。
3045
+ Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
3046
+ }
2979
3047
  // 委托周期锚点(R-01-009/AC-06):全部活动条目逐帧记账——锚点不因呈现翻转
2980
3048
  // (委托期与 awaiting 互转)或瞬时不可见而丢失,仅 dispose 时整体清除;周期内
2981
3049
  // 进度连续(含 settle 处理回合),周期外由宿主回合起点驱动(新回合归零)。
@@ -2991,13 +3059,15 @@ function apply(ctx) {
2991
3059
  progressAnchorById.set(entry.id, anchor);
2992
3060
  if (entry.kind === "running") {
2993
3061
  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 ?? {}));
3062
+ Object.assign(entry, projectionStats, { elapsedMs });
3063
+ if (detail) {
3064
+ // 列表投影短暂缺字段时,不覆盖此前已知的有效统计。
3065
+ detail.lastRuntimeStats = mergeRuntimeStats(projectionStats, detail.lastRuntimeStats);
3066
+ }
2997
3067
  // 回合进度:纯时间驱动 y = t/(t+k),半衰期每帧按最新实测速率现算,固定
2998
3068
  // k 下单调性由函数本身保证;k 变化时允许进度随速率回落而回退(委托周期
2999
3069
  // 连续、周期外回合切换归零,R-01-009/AC-06,C-014、C-044)。
3000
- entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS }) });
3070
+ entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
3001
3071
  }
3002
3072
  }
3003
3073
  // 历史区时间精化(R-01-010/AC-08、AC-09):从保留快照的 turnTimings 与已拉取的
@@ -3018,6 +3088,23 @@ function apply(ctx) {
3018
3088
  recentTotal = recentCandidates.length;
3019
3089
  const recent = recentCandidates.slice(0, recentVisibleCount);
3020
3090
  recentHasMore = recent.length < recentTotal;
3091
+ // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
3092
+ // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
3093
+ const recentDurationFallbackIds = new Set();
3094
+ for (const entry of recent) {
3095
+ const detail = sessionDetailsById.get(entry.id);
3096
+ const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
3097
+ const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
3098
+ const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
3099
+ const retained = detail?.lastRuntimeStats;
3100
+ Object.assign(entry, stats, retained ? {
3101
+ outputTokens: retained.outputTokens ?? stats.outputTokens,
3102
+ inputTokens: retained.inputTokens ?? stats.inputTokens,
3103
+ cacheHitPct: retained.cacheHitPct ?? stats.cacheHitPct,
3104
+ rateTokS: retained.rateTokS ?? stats.rateTokS,
3105
+ } : null);
3106
+ if (elapsedMs === null) recentDurationFallbackIds.add(entry.id);
3107
+ }
3021
3108
  syncRecentTimeClock(recent.length > 0);
3022
3109
  // 预览只对当前显示的 recent 卡计算(活动卡不显示预览);快照/历史引用不变时命中缓存。
3023
3110
  // 完成瞬间的窗口快照可能先有用户消息、后到 agent reply;缺任一预览时补读一次 history。
@@ -3038,7 +3125,10 @@ function apply(ctx) {
3038
3125
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
3039
3126
  }
3040
3127
  // 补充数据读取优先级:当前会话最优先,活动区先于当前已显示历史页(区内按显示顺序)。
3041
- const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
3128
+ const durationFallbackIds = new Set([
3129
+ ...active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id),
3130
+ ...recentDurationFallbackIds,
3131
+ ]);
3042
3132
  const detailIds = [...active, ...recent].map((entry) => entry.id);
3043
3133
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
3044
3134
  loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
@@ -3063,12 +3153,32 @@ function apply(ctx) {
3063
3153
  const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
3064
3154
  const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
3065
3155
  if (sig === lastSig) return;
3066
- const hueByWorkspace = resolveWorkspaceHues(visibleEntries.map((entry) => entry.workspaceKey));
3156
+ const colorByWorkspace = resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey));
3067
3157
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
3068
3158
  const migrations = [
3069
3159
  ...movedToRecentIds(prevRenderedActiveIds, active, recent).map((id) => ({ id, from: cardsById, to: recentCardsById })),
3070
3160
  ...movedToActiveIds(prevRenderedRecentIds, active, recent).map((id) => ({ id, from: recentCardsById, to: cardsById })),
3071
3161
  ];
3162
+ // 活动卡带焦点迁入历史时,把键盘焦点交给其上一个仍在活动区的卡片;
3163
+ // 若迁移卡已在活动区首位,则退到下一张,避免焦点落到历史区或 body。
3164
+ let focusAfterMigrationId = null;
3165
+ const focusedElement = document.activeElement;
3166
+ const activeIds = new Set(active.map((entry) => String(entry.id)));
3167
+ for (const { id, from } of migrations) {
3168
+ if (from !== cardsById) continue;
3169
+ const source = from.get(id)?.el;
3170
+ if (source === undefined || !source.contains(focusedElement)) continue;
3171
+ const activeCards = [...activeList.querySelectorAll(`.${CARD_CLASS}`)];
3172
+ const sourceIndex = activeCards.indexOf(source);
3173
+ if (sourceIndex < 0) continue;
3174
+ const target = activeCards
3175
+ .slice(0, sourceIndex)
3176
+ .reverse()
3177
+ .find((card) => activeIds.has(String(card.dataset.sessionId)))
3178
+ ?? activeCards.slice(sourceIndex + 1).find((card) => activeIds.has(String(card.dataset.sessionId)));
3179
+ focusAfterMigrationId = target?.dataset.sessionId ?? null;
3180
+ break;
3181
+ }
3072
3182
  const movePlans = prepareMoveGhosts(migrations);
3073
3183
  // 迁移帧内位置受影响的其它卡片与历史区段头:DOM 写入前量取当前视觉矩形
3074
3184
  // (R-01-010/AC-10);reduced-motion 下 movePlans 为空,FLIP 量取整体跳过。
@@ -3082,7 +3192,7 @@ function apply(ctx) {
3082
3192
  const aliveActive = new Set();
3083
3193
  for (const [index, entry] of active.entries()) {
3084
3194
  try {
3085
- renderCardIntoList(activeList, entry, cardsById, index, 1, hueByWorkspace);
3195
+ renderCardIntoList(activeList, entry, cardsById, index, 1, colorByWorkspace);
3086
3196
  } catch (error) {
3087
3197
  renderOk = false;
3088
3198
  logCardRenderError(entry.id, error);
@@ -3099,7 +3209,7 @@ function apply(ctx) {
3099
3209
  // 历史区容器首个子节点是段头(.dap-recent-head),卡片从 offset 1 开始。
3100
3210
  for (const [index, entry] of recent.entries()) {
3101
3211
  try {
3102
- renderCardIntoList(recentSection, entry, recentCardsById, index, 1, hueByWorkspace);
3212
+ renderCardIntoList(recentSection, entry, recentCardsById, index, 1, colorByWorkspace);
3103
3213
  } catch (error) {
3104
3214
  renderOk = false;
3105
3215
  logCardRenderError(entry.id, error);
@@ -3118,6 +3228,8 @@ function apply(ctx) {
3118
3228
  }
3119
3229
  runMoveGhosts(movePlans);
3120
3230
  if (shiftRects !== null) runShiftAnimations(shiftRects);
3231
+ ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
3232
+ if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
3121
3233
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
3122
3234
  const headerEl = pane.querySelector(".dap-header");
3123
3235
  const recentHeadEl = recentSection?.querySelector(".dap-recent-head") ?? null;