dsh-activity-pane 0.2.2 → 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.
package/src/client.mjs CHANGED
@@ -32,8 +32,12 @@ const INDENT_PX = 16;
32
32
  const MOBILE_BREAKPOINT = "767px";
33
33
  /** 运行卡时钟:只要存在运行中会话,就以该周期刷新时长显示。 */
34
34
  const CLOCK_MS = 1000;
35
+ /** 历史卡相对时间刷新周期;无需每秒重绘整列。 */
36
+ const RECENT_TIME_REFRESH_MS = 60_000;
35
37
  /** 冷数据读取并发池上限:慢网下避免几十张卡片的 models/history 一次性挤占通道。 */
36
38
  const LOAD_CONCURRENCY = 3;
39
+ /** 历史分页每批数量;后续批次只由底部按钮显式加载。 */
40
+ const RECENT_PAGE_SIZE = 10;
37
41
  /** 「回到顶部」悬浮按钮显隐阈值:scrollTop 超过该值(px)时显示(R-01-018/AC-01)。 */
38
42
  const TOP_THRESHOLD = 200;
39
43
 
@@ -117,21 +121,34 @@ const CSS = `
117
121
  touch-action: pan-y;
118
122
  -webkit-overflow-scrolling: touch;
119
123
  padding: 0 0 10px;
120
- }
121
- /* 滚动条仅在滚动时显示(R-01-004/AC-03,与外壳侧栏一致):thumb 默认透明,滚动中
122
- 经 data-scrolling 显示。Firefox 路径必须在 @supports 门内——非 auto 的
123
- scrollbar-color 会让 Chromium 丢弃该元素的 ::-webkit-scrollbar 规则。 */
124
+ /* Keep the native scrollbar just left of the edge handle, as in the host
125
+ sidebar; stable keeps the list geometry unchanged when overflow appears. */
126
+ margin-right: var(--dsh-scrollbar-width, 8px);
127
+ scrollbar-gutter: stable;
128
+ }
129
+ /* 滚动条在滚动或鼠标进入窗格时显示(R-01-004/AC-03,与外壳侧栏一致):thumb 默认透明,
130
+ 经 data-scrolling/data-pointer-inside 显示,悬停时读取主题 hover token。Firefox 路径必须在
131
+ @supports 门内——非 auto 的 scrollbar-color 会让 Chromium 丢弃该元素的 ::-webkit-scrollbar 规则。 */
124
132
  [data-dsh-activity-pane] .dap-scroll::-webkit-scrollbar-thumb {
125
133
  background: transparent;
126
134
  }
127
135
  [data-dsh-activity-pane] .dap-scroll[data-scrolling]::-webkit-scrollbar-thumb {
128
136
  background: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent));
129
137
  }
138
+ [data-dsh-activity-pane][data-pointer-inside] .dap-scroll::-webkit-scrollbar-thumb {
139
+ background: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent));
140
+ }
141
+ [data-dsh-activity-pane][data-pointer-inside] .dap-scroll::-webkit-scrollbar-thumb:hover {
142
+ background: var(--dsh-scrollbar-thumb-hover, color-mix(in srgb, currentColor 40%, transparent));
143
+ }
130
144
  @supports not selector(::-webkit-scrollbar) {
131
145
  [data-dsh-activity-pane] .dap-scroll { scrollbar-color: transparent transparent; }
132
146
  [data-dsh-activity-pane] .dap-scroll[data-scrolling] {
133
147
  scrollbar-color: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent)) transparent;
134
148
  }
149
+ [data-dsh-activity-pane][data-pointer-inside] .dap-scroll {
150
+ scrollbar-color: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent)) transparent;
151
+ }
135
152
  }
136
153
  /* 「回到顶部」悬浮图标按钮(R-01-018):窗格内右下角圆形按钮,纯图标无文字、不透明底色;
137
154
  默认 hidden,scrollTop 超阈值时由滚动监听揭隐。基类 display:flex 会压过 UA 的
@@ -188,6 +205,29 @@ const CSS = `
188
205
  color: color-mix(in srgb, currentColor 52%, transparent);
189
206
  text-transform: uppercase;
190
207
  }
208
+ /* 历史分页只在用户明确激活底部按钮后追加(R-01-019/AC-02)。 */
209
+ [data-dsh-activity-pane] .dap-recent-more {
210
+ align-self: center;
211
+ flex: none;
212
+ min-width: 132px;
213
+ padding: 5px 10px;
214
+ border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
215
+ border-radius: 6px;
216
+ background: color-mix(in srgb, currentColor 7%, transparent);
217
+ color: color-mix(in srgb, currentColor 70%, transparent);
218
+ font: inherit;
219
+ font-size: 11px;
220
+ line-height: 16px;
221
+ cursor: pointer;
222
+ }
223
+ [data-dsh-activity-pane] .dap-recent-more:hover,
224
+ [data-dsh-activity-pane] .dap-recent-more:focus-visible {
225
+ background: color-mix(in srgb, currentColor 13%, transparent);
226
+ color: inherit;
227
+ }
228
+ [data-dsh-activity-pane] .dap-recent-more:focus-visible { outline: 2px solid color-mix(in srgb, currentColor 45%, transparent); outline-offset: 1px; }
229
+ [data-dsh-activity-pane] .dap-recent-more[disabled] { cursor: wait; opacity: 0.6; }
230
+ [data-dsh-activity-pane] .dap-recent-more[hidden] { display: none; }
191
231
  /* 折叠:仅桌面生效;窄条 + 竖排标题与计数(R-01-011/AC-04)。 */
192
232
  [data-dsh-activity-pane] .dap-rail {
193
233
  display: none;
@@ -226,7 +266,8 @@ const CSS = `
226
266
  background: rgba(46, 42, 26, 0.97);
227
267
  animation: dap-await-pulse 1.2s ease-in-out infinite;
228
268
  }
229
- /* 桌面拖拽调宽手柄(R-01-015):右缘 6px 命中区,拖拽实时写入 --dap-width;
269
+ /* 桌面拖拽调宽手柄(R-01-015):右缘 6px 透明命中区,拖拽实时写入 --dap-width;
270
+ 滚动区在其左侧留出 native scrollbar 命中带,手柄只改变光标、不绘制 hover 高亮;
230
271
  折叠窄条与移动端抽屉不提供拖拽(下方两处媒体查询隐藏)。 */
231
272
  [data-dsh-activity-pane] .dap-resize {
232
273
  position: absolute;
@@ -236,10 +277,6 @@ const CSS = `
236
277
  touch-action: none;
237
278
  z-index: 6;
238
279
  }
239
- [data-dsh-activity-pane] .dap-resize:hover,
240
- [data-dsh-activity-pane] .dap-resize[data-dragging] {
241
- background: color-mix(in srgb, currentColor 18%, transparent);
242
- }
243
280
  /* 桌面:窗格作为中间列内的真实 flex 行元素参与布局——中间列被置为行方向,
244
281
  窗格固定宽、会话区弹性收缩,整个会话内容被真实挤到右边;折叠时收窄为窄条。 */
245
282
  @media (min-width: 768px) {
@@ -484,14 +521,10 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
484
521
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
485
522
  min-width: 0; margin: 2px 0 0;
486
523
  }
487
- /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
524
+ /* 等待类型胶囊独立同行:耗时回到统计行最右侧,不参与等待脉冲。 */
488
525
  [data-dsh-activity-pane] .dap-await-head {
489
526
  display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
490
527
  }
491
- [data-dsh-activity-pane] .dap-await-head .dap-token-time {
492
- margin-left: auto; flex: none; font-size: 10px; line-height: 14px;
493
- color: #8f9aaa; font-variant-numeric: tabular-nums;
494
- }
495
528
  [data-dsh-activity-pane] .dap-note-row {
496
529
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
497
530
  }
@@ -510,7 +543,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
510
543
  color: color-mix(in srgb, currentColor 82%, transparent);
511
544
  background: color-mix(in srgb, currentColor 14%, transparent);
512
545
  }
513
- /* 运行卡富化(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
546
+ /* 运行卡与最近卡统计(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
514
547
  [data-dsh-activity-pane] .dap-progress {
515
548
  display: flex; align-items: center; gap: 7px; min-width: 0;
516
549
  }
@@ -945,12 +978,15 @@ function livenessFromSnapshot(snap) {
945
978
  return { startTime };
946
979
  }
947
980
 
948
- function fmtRecentTime(ts) {
981
+ /** 历史卡时间同时给出本地绝对日期时分与相对年龄;跨年份的绝对日期带年份。 */
982
+ function fmtRecentTime(ts, now = Date.now()) {
949
983
  try {
950
- return `最近 ${new Date(ts).toLocaleTimeString([], {
951
- hour: "2-digit",
952
- minute: "2-digit",
953
- })}`;
984
+ const value = Number(ts);
985
+ const current = Number(now);
986
+ const absolute = fmtAbsoluteDateTime(ts, now);
987
+ if (!absolute) return "";
988
+ const relative = fmtRelativeAge(current - value);
989
+ return relative ? `最后活动 · ${absolute} · ${relative}` : `最后活动 · ${absolute}`;
954
990
  } catch {
955
991
  return "";
956
992
  }
@@ -984,6 +1020,7 @@ function apply(ctx) {
984
1020
  let sessionUnsubscribe = null;
985
1021
  let workspaceUnsubscribe = null;
986
1022
  let clockTimer = null;
1023
+ let recentTimeTimer = null;
987
1024
  let syncScheduled = false;
988
1025
  let lastSig = "";
989
1026
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
@@ -1036,11 +1073,16 @@ function apply(ctx) {
1036
1073
  const cardsById = new Map();
1037
1074
  /** 历史区卡片 id → { el } 复用表。 */
1038
1075
  const recentCardsById = new Map();
1076
+ /** 历史区渐进呈现状态:候选由核心完整派生,DOM 与详情读取只覆盖当前前缀。 */
1077
+ let recentVisibleCount = RECENT_PAGE_SIZE;
1078
+ let recentTotal = 0;
1079
+ let recentHasMore = false;
1080
+ let recentAppendQueued = false;
1039
1081
  /** 运行中会话原生快照订阅:id → { unsubscribe, liveness, snapshot }。 */
1040
1082
  const livenessById = new Map();
1041
1083
  /** 委托周期进度锚点记账:id → progressAnchor 状态(R-01-009/AC-06)。 */
1042
1084
  const progressAnchorById = new Map();
1043
- /** 订阅停止后保留最近快照,供 awaiting/recent 卡继续显示上下文。 */
1085
+ /** 订阅停止后保留最近快照与最后已知运行统计,供 awaiting/recent 卡继续显示上下文。 */
1044
1086
  const sessionDetailsById = new Map();
1045
1087
  /** 会话跳转的单一重试链;避免重复点击叠加 refresh/timer。 */
1046
1088
  const openRetryStates = new Map();
@@ -1449,12 +1491,25 @@ function apply(ctx) {
1449
1491
  window.dispatchEvent(new Event("resize"));
1450
1492
  } catch {}
1451
1493
  }
1494
+ function loadMoreRecent() {
1495
+ if (disposed || !recentHasMore || recentAppendQueued) return false;
1496
+ const nextCount = Math.min(recentVisibleCount + RECENT_PAGE_SIZE, recentTotal);
1497
+ if (nextCount <= recentVisibleCount) return false;
1498
+ recentAppendQueued = true;
1499
+ recentVisibleCount = nextCount;
1500
+ queueSync();
1501
+ return true;
1502
+ }
1452
1503
  function bindPaneControls(pane) {
1453
1504
  const header = pane.querySelector(".dap-header");
1454
1505
  const rail = pane.querySelector(".dap-rail");
1455
1506
  const resize = pane.querySelector(".dap-resize");
1456
1507
  const scroll = pane.querySelector(".dap-scroll");
1457
1508
  const topBtn = pane.querySelector(".dap-top");
1509
+ const recentMore = pane.querySelector(".dap-recent-more");
1510
+ const onRecentMoreClick = () => {
1511
+ if (loadMoreRecent() && recentMore !== null) recentMore.disabled = true;
1512
+ };
1458
1513
  // 标题行整体即收起控件,两端断点一致:桌面折叠为窄条(R-01-011/AC-03);
1459
1514
  // 移动端断点解释为收起抽屉,而非折叠窄条(R-01-008/AC-02、R-01-011/AC-06)。
1460
1515
  const onHeaderActivate = () => {
@@ -1477,6 +1532,15 @@ function apply(ctx) {
1477
1532
  const syncTopBtn = () => {
1478
1533
  if (topBtn !== null && scroll !== null) topBtn.hidden = scroll.scrollTop <= TOP_THRESHOLD;
1479
1534
  };
1535
+ // 鼠标进入窗格即显示 scrollbar(R-01-004/AC-03);触摸指针不改变桌面滚动条可见状态。
1536
+ const onPanePointerEnter = (event) => {
1537
+ if (event.pointerType !== "mouse") return;
1538
+ pane.setAttribute("data-pointer-inside", "");
1539
+ };
1540
+ const onPanePointerLeave = (event) => {
1541
+ if (event.pointerType !== "mouse") return;
1542
+ pane.removeAttribute("data-pointer-inside");
1543
+ };
1480
1544
  const onRailClick = () => {
1481
1545
  collapsed = false;
1482
1546
  pane.setAttribute("data-collapsed", "false");
@@ -1519,8 +1583,9 @@ function apply(ctx) {
1519
1583
  resize.addEventListener("pointercancel", onResizeUp);
1520
1584
  resize.setPointerCapture(event.pointerId);
1521
1585
  };
1522
- // 滚动条仅滚动时显示(R-01-004/AC-03):滚动即置位,停滚 600ms 后隐藏。
1523
- // 同一监听承载「回到顶部」按钮显隐(R-01-018/AC-01、AC-03):回顶后的收口不经额外事件,
1586
+ // 滚动条随滚动或窗格内鼠标指针显示(R-01-004/AC-03):滚动即置位,停滚 600ms 后收口;
1587
+ // 指针离开后若仍在滚动拖尾内则保持显示,拖尾结束后隐藏。同一监听承载「回到顶部」按钮显隐
1588
+ //(R-01-018/AC-01、AC-03):回顶后的收口不经额外事件,
1524
1589
  // 由平滑滚动触发的 scroll 事件自然完成。
1525
1590
  let scrollHideTimer = null;
1526
1591
  const onScroll = () => {
@@ -1541,14 +1606,21 @@ function apply(ctx) {
1541
1606
  header?.addEventListener("click", onHeaderActivate);
1542
1607
  header?.addEventListener("keydown", onHeaderKeydown);
1543
1608
  rail?.addEventListener("click", onRailClick);
1609
+ pane.addEventListener("pointerenter", onPanePointerEnter);
1610
+ pane.addEventListener("pointerleave", onPanePointerLeave);
1544
1611
  scroll?.addEventListener("scroll", onScroll, { passive: true });
1612
+ recentMore?.addEventListener("click", onRecentMoreClick);
1545
1613
  topBtn?.addEventListener("click", onTopClick);
1546
1614
  resize?.addEventListener("pointerdown", onResizeDown);
1547
1615
  return () => {
1548
1616
  header?.removeEventListener("click", onHeaderActivate);
1549
1617
  header?.removeEventListener("keydown", onHeaderKeydown);
1550
1618
  rail?.removeEventListener("click", onRailClick);
1619
+ pane.removeEventListener("pointerenter", onPanePointerEnter);
1620
+ pane.removeEventListener("pointerleave", onPanePointerLeave);
1621
+ pane.removeAttribute("data-pointer-inside");
1551
1622
  scroll?.removeEventListener("scroll", onScroll);
1623
+ recentMore?.removeEventListener("click", onRecentMoreClick);
1552
1624
  if (scrollHideTimer !== null) clearTimeout(scrollHideTimer);
1553
1625
  topBtn?.removeEventListener("click", onTopClick);
1554
1626
  resize?.removeEventListener("pointerdown", onResizeDown);
@@ -1578,7 +1650,8 @@ function apply(ctx) {
1578
1650
  <div class="dap-scroll">
1579
1651
  <div class="dap-list" tabindex="-1"><div class="dap-tracks" aria-hidden="true"></div></div>
1580
1652
  <div class="dap-recent">
1581
- <div class="dap-recent-head"><span>最近历史 · 24h</span></div>
1653
+ <div class="dap-recent-head"><span>最近历史</span></div>
1654
+ <button class="dap-recent-more" type="button" hidden>加载更多...</button>
1582
1655
  </div>
1583
1656
  </div>
1584
1657
  <button class="dap-top" type="button" aria-label="回到顶部" title="回到顶部" hidden></button>
@@ -1684,7 +1757,10 @@ function apply(ctx) {
1684
1757
  const agentLabel = makeEl("span", "dap-history-label");
1685
1758
  agentLabel.textContent = "助手";
1686
1759
  agentLine.append(agentIcon, agentLabel, makeEl("span", "dap-history-separator"), makeEl("span", "dap-history-text"));
1687
- 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")];
1688
1764
  }
1689
1765
  if (kind === "awaiting") {
1690
1766
  const row = makeEl("div", "dap-row");
@@ -1698,10 +1774,13 @@ function apply(ctx) {
1698
1774
  const noteRow = makeEl("div", "dap-note-row");
1699
1775
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
1700
1776
  const awaitHead = makeEl("div", "dap-await-head");
1701
- 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;
1702
1781
  const foot = makeEl("div", "dap-foot");
1703
1782
  foot.append(awaitHead, noteRow);
1704
- return [head, row, makeEl("div", "dap-trace"), foot];
1783
+ return [head, row, makeEl("div", "dap-trace"), statsRow, foot];
1705
1784
  }
1706
1785
  const row = makeEl("div", "dap-row");
1707
1786
  row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
@@ -2062,14 +2141,19 @@ function apply(ctx) {
2062
2141
  }
2063
2142
  }
2064
2143
 
2065
- /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2144
+ /** 统计行渲染:运行卡、等待卡与最近卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2066
2145
  function renderTokenStats(el, entry) {
2067
2146
  let stats = el.querySelector(".dap-token-stats");
2068
2147
  if (stats === null) {
2069
2148
  stats = makeEl("div", "dap-token-stats");
2070
2149
  stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
2071
2150
  stats.hidden = true;
2072
- 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);
2073
2157
  }
2074
2158
  let mainTextEl = stats.querySelector(".dap-token-main");
2075
2159
  let timeEl = stats.querySelector(".dap-token-time");
@@ -2091,25 +2175,9 @@ function apply(ctx) {
2091
2175
  if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
2092
2176
  }
2093
2177
 
2094
- /** 等待卡耗时写入:与等待类型胶囊同一行靠右,旧骨架缺该行时就地补齐。 */
2095
- function renderAwaitingDuration(el, entry) {
2096
- const foot = el.querySelector(".dap-foot");
2097
- if (foot === null) return;
2098
- let head = foot.querySelector(".dap-await-head");
2099
- if (head === null) {
2100
- const capsule = foot.querySelector(".dap-capsule");
2101
- if (capsule === null) return;
2102
- head = makeEl("div", "dap-await-head");
2103
- capsule.replaceWith(head);
2104
- head.append(capsule);
2105
- }
2106
- let time = head.querySelector(".dap-token-time");
2107
- if (time === null) {
2108
- time = makeEl("span", "dap-token-time");
2109
- head.append(time);
2110
- }
2111
- const text = Number.isFinite(entry.elapsedMs) && entry.elapsedMs >= 0 ? fmtElapsedMs(entry.elapsedMs) : "";
2112
- if (time.textContent !== text) time.textContent = text;
2178
+ /** 旧热装等待骨架清理:耗时已迁回统计行,去掉胶囊同行残留的时长节点。 */
2179
+ function removeAwaitingHeadDuration(el) {
2180
+ el.querySelector(".dap-await-head .dap-token-time")?.remove();
2113
2181
  }
2114
2182
 
2115
2183
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
@@ -2244,12 +2312,14 @@ function apply(ctx) {
2244
2312
  restoreTextField(text, previews[i]);
2245
2313
  }
2246
2314
  }
2315
+ renderTokenStats(el, entry);
2247
2316
  }
2248
2317
 
2249
2318
  if (entry.kind === "awaiting") {
2250
2319
  const traceContainer = el.querySelector(".dap-trace");
2251
2320
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
2252
- renderAwaitingDuration(el, entry);
2321
+ removeAwaitingHeadDuration(el);
2322
+ renderTokenStats(el, entry);
2253
2323
  const confirm = el.querySelector(".dap-confirm");
2254
2324
  if (confirm !== null) {
2255
2325
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -2358,6 +2428,16 @@ function apply(ctx) {
2358
2428
  }
2359
2429
  }
2360
2430
 
2431
+ /** 历史卡相对时间只需分钟级刷新;无历史卡时停止定时器,避免空窗格常驻唤醒。 */
2432
+ function syncRecentTimeClock(wanted) {
2433
+ if (wanted && recentTimeTimer === null) {
2434
+ recentTimeTimer = setInterval(() => queueSync(), RECENT_TIME_REFRESH_MS);
2435
+ } else if (!wanted && recentTimeTimer !== null) {
2436
+ clearInterval(recentTimeTimer);
2437
+ recentTimeTimer = null;
2438
+ }
2439
+ }
2440
+
2361
2441
  function getSessionSnapshot(session) {
2362
2442
  try {
2363
2443
  return session?.getSnapshot?.() ?? null;
@@ -2566,11 +2646,14 @@ function apply(ctx) {
2566
2646
  if (entry.waitClass === "blocked" || entry.waitClass === "done" || entry.waitClass === "error")
2567
2647
  rec.el.setAttribute("data-wait", entry.waitClass);
2568
2648
  else rec.el.removeAttribute("data-wait");
2649
+ const recentTimeText = entry.kind === "recent" ? fmtRecentTime(entry.activityAt) : "";
2569
2650
  rec.el.setAttribute(
2570
2651
  "aria-label",
2571
2652
  `${entry.workspaceTitle ? entry.workspaceTitle + " - " : ""}${entry.title}${
2572
2653
  entry.pendingText ? "," + entry.pendingText : ""
2573
- }${(entry.waitClass === "done" || entry.waitClass === "error") && entry.noteText ? "," + entry.noteText : ""}`,
2654
+ }${(entry.waitClass === "done" || entry.waitClass === "error") && entry.noteText ? "," + entry.noteText : ""}${
2655
+ recentTimeText ? "," + recentTimeText : ""
2656
+ }`,
2574
2657
  );
2575
2658
  renderCardInto(rec.el, entry, hueByWorkspace);
2576
2659
  // 只有顺序/归属真正变化时才移动 DOM:每次渲染无条件 appendChild 会把所有
@@ -2761,6 +2844,10 @@ function apply(ctx) {
2761
2844
  pulseSignature = "";
2762
2845
  prevRenderedActiveIds = new Set();
2763
2846
  prevRenderedRecentIds = new Set();
2847
+ recentVisibleCount = RECENT_PAGE_SIZE;
2848
+ recentTotal = 0;
2849
+ recentHasMore = false;
2850
+ recentAppendQueued = false;
2764
2851
  // 旧窗格已脱离文档:其在飞平移的 transitionend 不再触发,逐元素取消避免残留。
2765
2852
  for (const el of [...shiftCleanups.keys()]) cancelShift(el);
2766
2853
  }
@@ -2780,6 +2867,8 @@ function apply(ctx) {
2780
2867
  }
2781
2868
  }
2782
2869
  const listState = listLoadState(snapshot);
2870
+ // 只消费上一轮追加请求;列表短暂 pending 时保留已展开页,ready 后继续从同一前缀呈现。
2871
+ recentAppendQueued = false;
2783
2872
  const workspaceSnapshot = getSnapshot(workspaces, "list");
2784
2873
  const workspaceItems = workspaceSnapshot?.items ?? [];
2785
2874
  const archivedSessionIds = workspaceSnapshot?.archivedSessionIds ?? [];
@@ -2875,11 +2964,11 @@ function apply(ctx) {
2875
2964
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
2876
2965
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
2877
2966
  const projection = snapshot?.byId?.[entry.id]?.projectionValues;
2878
- const stats = projection?.sessionStats;
2879
- const rateTokS =
2880
- stats && Number.isFinite(stats.decodeMs) && stats.decodeMs > 0
2881
- ? stats.decodeTokens / (stats.decodeMs / 1000)
2882
- : null;
2967
+ const projectionStats = statsFromProjection(projection);
2968
+ if (entry.kind === "awaiting") {
2969
+ // 等待期间冻结运行停止前的最后已知统计;刷新/无留存时回退当前列表投影。
2970
+ Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
2971
+ }
2883
2972
  // 委托周期锚点(R-01-009/AC-06):全部活动条目逐帧记账——锚点不因呈现翻转
2884
2973
  // (委托期与 awaiting 互转)或瞬时不可见而丢失,仅 dispose 时整体清除;周期内
2885
2974
  // 进度连续(含 settle 处理回合),周期外由宿主回合起点驱动(新回合归零)。
@@ -2895,13 +2984,15 @@ function apply(ctx) {
2895
2984
  progressAnchorById.set(entry.id, anchor);
2896
2985
  if (entry.kind === "running") {
2897
2986
  const elapsedMs = Number.isFinite(anchor.anchor) ? Math.max(0, now - anchor.anchor) : null;
2898
- const outputTokens = projection?.tokenUsage?.outputTokens ?? null;
2899
- Object.assign(entry, runtimeStats({ elapsedMs, outputTokens, rateTokS }));
2900
- Object.assign(entry, usageSummary(projection?.tokenUsage ?? {}));
2987
+ Object.assign(entry, projectionStats, { elapsedMs });
2988
+ if (detail) {
2989
+ // 列表投影短暂缺字段时,不覆盖此前已知的有效统计。
2990
+ detail.lastRuntimeStats = mergeRuntimeStats(projectionStats, detail.lastRuntimeStats);
2991
+ }
2901
2992
  // 回合进度:纯时间驱动 y = t/(t+k),半衰期每帧按最新实测速率现算,固定
2902
2993
  // k 下单调性由函数本身保证;k 变化时允许进度随速率回落而回退(委托周期
2903
2994
  // 连续、周期外回合切换归零,R-01-009/AC-06,C-014、C-044)。
2904
- entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS }) });
2995
+ entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
2905
2996
  }
2906
2997
  }
2907
2998
  // 历史区时间精化(R-01-010/AC-08、AC-09):从保留快照的 turnTimings 与已拉取的
@@ -2918,8 +3009,29 @@ function apply(ctx) {
2918
3009
  }
2919
3010
  if (detail.memoTurnEnd != null) turnEnds[id] = detail.memoTurnEnd;
2920
3011
  }
2921
- const recent = buildRecent(snapshot, workspaceItems, now, undefined, sessionDetailsById, archivedSessionIds, completeAcksById, delegatingIds, turnEnds);
2922
- // 预览只对 recent 卡计算(活动卡不显示预览);快照/历史引用不变时命中缓存。
3012
+ const recentCandidates = buildRecent(snapshot, workspaceItems, now, sessionDetailsById, archivedSessionIds, completeAcksById, delegatingIds, turnEnds);
3013
+ recentTotal = recentCandidates.length;
3014
+ const recent = recentCandidates.slice(0, recentVisibleCount);
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
+ }
3033
+ syncRecentTimeClock(recent.length > 0);
3034
+ // 预览只对当前显示的 recent 卡计算(活动卡不显示预览);快照/历史引用不变时命中缓存。
2923
3035
  // 完成瞬间的窗口快照可能先有用户消息、后到 agent reply;缺任一预览时补读一次 history。
2924
3036
  const previewFallbackIds = new Set();
2925
3037
  for (const entry of recent) {
@@ -2937,8 +3049,11 @@ function apply(ctx) {
2937
3049
  if (!entry.userPreview || !entry.agentPreview) previewFallbackIds.add(entry.id);
2938
3050
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
2939
3051
  }
2940
- // 补充数据读取优先级:当前会话最优先,活动区先于历史区(区内按显示顺序)。
2941
- const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
3052
+ // 补充数据读取优先级:当前会话最优先,活动区先于当前已显示历史页(区内按显示顺序)。
3053
+ const durationFallbackIds = new Set([
3054
+ ...active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id),
3055
+ ...recentDurationFallbackIds,
3056
+ ]);
2942
3057
  const detailIds = [...active, ...recent].map((entry) => entry.id);
2943
3058
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
2944
3059
  loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
@@ -2959,7 +3074,9 @@ function apply(ctx) {
2959
3074
  // listState 参与签名:空列表从 pending/error → ready 时卡集合不变,若只比较卡片
2960
3075
  // 会被提前返回冻结在「加载中」/「列表加载失败」;数量胶囊可见面变化同样需要
2961
3076
  // 进入渲染,以便与当前可见等待卡末行重新对相(R-01-002/AC-07)。
2962
- const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface]);
3077
+ // 历史卡的相对活动时间随分钟级时钟变化,纳入签名后只在文案实际变化时重绘。
3078
+ const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
3079
+ const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
2963
3080
  if (sig === lastSig) return;
2964
3081
  const hueByWorkspace = resolveWorkspaceHues(visibleEntries.map((entry) => entry.workspaceKey));
2965
3082
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
@@ -3008,6 +3125,11 @@ function apply(ctx) {
3008
3125
  if (recentSection !== null) {
3009
3126
  recentSection.hidden = listState === "ready" && recent.length === 0;
3010
3127
  ensureListStatus(recentSection, listState !== "ready" && recent.length === 0, listState === "loading" ? "加载中…" : "列表加载失败", { loading: listState === "loading" });
3128
+ const recentMore = recentSection.querySelector(".dap-recent-more");
3129
+ if (recentMore !== null) {
3130
+ recentMore.hidden = !recentHasMore;
3131
+ recentMore.disabled = recentAppendQueued;
3132
+ }
3011
3133
  }
3012
3134
  runMoveGhosts(movePlans);
3013
3135
  if (shiftRects !== null) runShiftAnimations(shiftRects);
@@ -3068,6 +3190,8 @@ function apply(ctx) {
3068
3190
  const headerExpanded = collapsed ? "false" : "true";
3069
3191
  if (headerEl !== null && headerEl.getAttribute("aria-expanded") !== headerExpanded)
3070
3192
  headerEl.setAttribute("aria-expanded", headerExpanded);
3193
+ // 历史分页只由底部按钮显式触发;追加只排队一帧,避免重复点击造成重复增长
3194
+ //(R-01-019/AC-01~AC-03)。
3071
3195
 
3072
3196
  // 渲染签名在整轮 DOM 写入全部成功后提交:任何一步失败都保留下一轮
3073
3197
  // 同步重试的机会,避免故障被签名吞掉后卡片永久滞留(R-01-013/AC-02)。
@@ -3252,6 +3376,7 @@ function apply(ctx) {
3252
3376
  window.removeEventListener("pageshow", onPageShow);
3253
3377
  completeAcksById.clear();
3254
3378
  if (clockTimer !== null) clearInterval(clockTimer);
3379
+ if (recentTimeTimer !== null) clearInterval(recentTimeTimer);
3255
3380
  if (e2eListReleaseTimer !== null) clearTimeout(e2eListReleaseTimer);
3256
3381
  for (const [timer, resolve] of e2eModelDelayWaiters) {
3257
3382
  clearTimeout(timer);