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.
@@ -9,7 +9,7 @@ window.__ModuleLoader__.load({
9
9
  //
10
10
  // 数据源约定(来自 @deepseek-ai/dsh-client-runtime 的 sessions.list 快照):
11
11
  // ids: SessionId[] —— 宿主列表顺序(祖先在前的 lineage 顺序)
12
- // byId[id]: { id, displayTitle, title?, cwd?, parentId?, running, completed?,
12
+ // byId[id]: { id, displayTitle, title?, cwd?, parentId?, origin?, running, completed?,
13
13
  // pendingInteraction?: 'approval'|'plan-review'|'question', blank, ... }
14
14
  // current: SessionId | undefined
15
15
  // subagentsByParent: { [parentId]: { entries: [{ id, label, ... }] } }
@@ -17,8 +17,8 @@ window.__ModuleLoader__.load({
17
17
  // 以及 workspaces.list 的 items: [{ title, path, sessionIds }]。
18
18
  //
19
19
  // 展示规则:
20
- // 主会话(无有效 parentId):running || completed || pendingInteraction 都显示;
21
- // 子代理:仅 running || pendingInteraction 时显示(结束后即消失);
20
+ // 主会话(非有效子代理行):running || completed || pendingInteraction 都显示;
21
+ // 子代理(origin === 'subagent' 且 parentId 有效):仅 running || pendingInteraction 时显示(结束后即消失);
22
22
  // pendingInteraction 总是优先视为"等待用户行动"(即使在 running 中)。
23
23
 
24
24
  const PENDING_LABELS = {
@@ -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,
@@ -1256,11 +1281,11 @@ function workspaceInfoForSession(sessionId, workspaceItems, byId = {}) {
1256
1281
  }
1257
1282
 
1258
1283
  /**
1259
- * 工作区徽标色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1284
+ * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1260
1285
  * djb2 哈希经雪崩终混(异或右移 + 乘法,把高位熵折入低位,消除 djb2 低位分布
1261
1286
  * 聚集;每步 >>> 0 保持无符号,异或结果可能带符号位)后在避开红色警戒区的
1262
1287
  * 色相弧 [30°,320°] 上均匀取色(30 + hash % 291),输出 [30,320] 整数。
1263
- * 同一身份恒得同一色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1288
+ * 同一身份恒得同一基色色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1264
1289
  * 刷新后不变;空身份返回 null。
1265
1290
  */
1266
1291
  function workspaceHue(key) {
@@ -1279,34 +1304,99 @@ function workspaceHue(key) {
1279
1304
  return 30 + (hash % 291);
1280
1305
  }
1281
1306
 
1282
- const WORKSPACE_HUE_ANCHORS = [55, 100, 145, 190, 235, 280, 325];
1307
+ const WORKSPACE_COLOR_SLOTS = Object.freeze([
1308
+ { slot: 0, hue: 55, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1309
+ { slot: 1, hue: 100, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1310
+ { slot: 2, hue: 145, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1311
+ { slot: 3, hue: 190, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1312
+ { slot: 4, hue: 235, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1313
+ { slot: 5, hue: 280, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1314
+ { slot: 6, hue: 325, darkL: 0.78, darkC: 0.16, lightL: 0.48, lightC: 0.15 },
1315
+ { slot: 7, hue: 77, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1316
+ { slot: 8, hue: 122, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1317
+ { slot: 9, hue: 167, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1318
+ { slot: 10, hue: 257, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1319
+ { slot: 11, hue: 302, darkL: 0.64, darkC: 0.15, lightL: 0.36, lightC: 0.15 },
1320
+ ].map(Object.freeze));
1321
+
1322
+ const WORKSPACE_BACKGROUND_SLOTS = Object.freeze([
1323
+ {
1324
+ slot: 0,
1325
+ dark: Object.freeze({ l: 0.30, c: 0.05, mix: "26%", borderMix: "46%" }),
1326
+ light: Object.freeze({ l: 0.92, c: 0.05, mix: "20%", borderMix: "34%" }),
1327
+ },
1328
+ {
1329
+ slot: 1,
1330
+ dark: Object.freeze({ l: 0.38, c: 0.06, mix: "31%", borderMix: "52%" }),
1331
+ light: Object.freeze({ l: 0.84, c: 0.06, mix: "27%", borderMix: "42%" }),
1332
+ },
1333
+ {
1334
+ slot: 2,
1335
+ dark: Object.freeze({ l: 0.46, c: 0.07, mix: "36%", borderMix: "58%" }),
1336
+ light: Object.freeze({ l: 0.76, c: 0.07, mix: "34%", borderMix: "50%" }),
1337
+ },
1338
+ ].map(Object.freeze));
1339
+
1340
+ const WORKSPACE_PRIMARY_SLOT_COUNT = 7;
1341
+ const WORKSPACE_SECONDARY_SLOT_COUNT = WORKSPACE_COLOR_SLOTS.length - WORKSPACE_PRIMARY_SLOT_COUNT;
1342
+
1343
+ function workspaceSlotProbe(start, count, step) {
1344
+ return Array.from({ length: count }, (_, offset) => (start + offset * step) % count);
1345
+ }
1283
1346
 
1284
1347
  /**
1285
- * 同屏工作区色相消解(R-01-003/AC-08、AC-12):身份去重排序后,以稳定基色
1286
- * 确定七个 OKLCH 感知锚点的起始槽;撞槽时按步进 3 跨色区探测。超过七个
1287
- * 工作区后选择当前使用次数最少的槽,使复用均衡且确定。
1348
+ * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):前七个身份沿原七色
1349
+ * OKLCH 主槽位分配,主槽位占满后再使用五个受控明度/色相补充槽位;每个前景槽位
1350
+ * 内优先使用未用的三个背景变体;超过三十六个复合身份后按槽位使用次数均衡复用。
1288
1351
  */
1289
- function resolveWorkspaceHues(keys) {
1352
+ function resolveWorkspaceColors(keys) {
1290
1353
  const identities = [...new Set((Array.isArray(keys) ? keys : []).map(cleanText).filter(Boolean))].sort();
1291
- const uses = WORKSPACE_HUE_ANCHORS.map(() => 0);
1354
+ const uses = WORKSPACE_COLOR_SLOTS.map(() => 0);
1355
+ const backgroundUses = WORKSPACE_COLOR_SLOTS.map(() => WORKSPACE_BACKGROUND_SLOTS.map(() => 0));
1292
1356
  const resolved = new Map();
1293
1357
  for (const identity of identities) {
1294
- const start = (workspaceHue(identity) - 30) % WORKSPACE_HUE_ANCHORS.length;
1295
- let chosen = start;
1296
- for (let offset = 0; offset < WORKSPACE_HUE_ANCHORS.length; offset += 1) {
1297
- const candidate = (start + offset * 3) % WORKSPACE_HUE_ANCHORS.length;
1298
- if (uses[candidate] < uses[chosen]) chosen = candidate;
1299
- if (uses[candidate] === 0) {
1300
- chosen = candidate;
1301
- break;
1302
- }
1358
+ const baseHue = workspaceHue(identity);
1359
+ const primaryOrder = workspaceSlotProbe(
1360
+ (baseHue - 30) % WORKSPACE_PRIMARY_SLOT_COUNT,
1361
+ WORKSPACE_PRIMARY_SLOT_COUNT,
1362
+ 3,
1363
+ );
1364
+ const secondaryOrder = workspaceSlotProbe(
1365
+ (baseHue - 30) % WORKSPACE_SECONDARY_SLOT_COUNT,
1366
+ WORKSPACE_SECONDARY_SLOT_COUNT,
1367
+ 2,
1368
+ ).map((index) => WORKSPACE_PRIMARY_SLOT_COUNT + index);
1369
+ const order = [...primaryOrder, ...secondaryOrder];
1370
+ const backgroundOrder = workspaceSlotProbe(
1371
+ (baseHue - 30) % WORKSPACE_BACKGROUND_SLOTS.length,
1372
+ WORKSPACE_BACKGROUND_SLOTS.length,
1373
+ 2,
1374
+ );
1375
+ const availableForegroundOrder = order.filter((index) => backgroundOrder.some((backgroundSlot) => backgroundUses[index][backgroundSlot] === 0));
1376
+ const foregroundOrder = availableForegroundOrder.length > 0 ? availableForegroundOrder : order;
1377
+ let foregroundSlot = foregroundOrder.find((index) => uses[index] === 0);
1378
+ if (foregroundSlot === undefined) {
1379
+ foregroundSlot = foregroundOrder.reduce(
1380
+ (best, index) => (uses[index] < uses[best] ? index : best),
1381
+ foregroundOrder[0],
1382
+ );
1303
1383
  }
1304
- uses[chosen] += 1;
1305
- resolved.set(identity, WORKSPACE_HUE_ANCHORS[chosen]);
1384
+ uses[foregroundSlot] += 1;
1385
+ const backgroundUsesForForeground = backgroundUses[foregroundSlot];
1386
+ const backgroundSlot = backgroundOrder.reduce(
1387
+ (best, index) => (backgroundUsesForForeground[index] < backgroundUsesForForeground[best] ? index : best),
1388
+ backgroundOrder[0],
1389
+ );
1390
+ backgroundUsesForForeground[backgroundSlot] += 1;
1391
+ resolved.set(identity, {
1392
+ foreground: WORKSPACE_COLOR_SLOTS[foregroundSlot],
1393
+ background: WORKSPACE_BACKGROUND_SLOTS[backgroundSlot],
1394
+ });
1306
1395
  }
1307
1396
  return resolved;
1308
1397
  }
1309
1398
 
1399
+
1310
1400
  /** 主会话按左侧工作区顺序排序的权重;不在任何 workspace 的排在最后保持 lineage 顺序。 */
1311
1401
  function workspaceRank(workspaceItems) {
1312
1402
  const wsIndex = new Map();
@@ -1361,6 +1451,7 @@ function mainTitle(byId, id) {
1361
1451
  * - 主会话 running(且无 pending)或处于委托周期(含后代耗尽空窗)→ 'running'
1362
1452
  * - 主会话 pendingInteraction / completed / errorReminder → 'awaiting'(等待用户行动)
1363
1453
  * - 子代理 running / pending 或存在活动后代 → 'subagent',自身与后代均不活动则不显示
1454
+ * - 普通 fork 虽有 parentId,但 origin 缺失,仍按独立主会话处理
1364
1455
  * completions(完成确认与错误提醒,R-01-002/AC-05、AC-13、R-01-010/AC-06):Map id →
1365
1456
  * { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt },由渲染层从宿主侧 ack 状态
1366
1457
  * 通道注入;其中完成提醒成立(completionReminder)或错误提醒成立(errorReminder)的
@@ -1368,21 +1459,38 @@ function mainTitle(byId, id) {
1368
1459
  * 记账派生):集合内会话视同处于委托周期——后代耗尽至 settle 处理回合启动的
1369
1460
  * 空窗内仍保持运行呈现、完成/错误提醒不生效;条目的 descendantActive 字段
1370
1461
  * 始终为当帧原始后代活性(供进度锚点记账判定耗尽),不受 delegatingIds 影响。
1462
+ * archivedIds(工作区服务的注册表全局归档集合):归档会话及其有效子代理子树不产出活动条目,
1463
+ * 普通 fork 仅因共享来源 parentId 不受影响;避免会话服务仍保留旧行或完成提醒时在窗格中滞留。
1371
1464
  */
1372
- function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null) {
1465
+ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = []) {
1373
1466
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1374
1467
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1375
1468
  const current = snapshot?.current ?? null;
1376
1469
  const subagentsByParent = isRecord(snapshot?.subagentsByParent)
1377
1470
  ? snapshot.subagentsByParent
1378
1471
  : {};
1472
+ const archived = archivedIds instanceof Set
1473
+ ? new Set([...archivedIds].map((id) => String(id)))
1474
+ : new Set((archivedIds ?? []).map((id) => String(id)));
1475
+ const isArchived = (id) => {
1476
+ const seen = new Set();
1477
+ let currentId = id;
1478
+ while (currentId !== undefined && currentId !== null) {
1479
+ const key = String(currentId);
1480
+ if (archived.has(key) || seen.has(key)) return archived.has(key);
1481
+ seen.add(key);
1482
+ currentId = subagentParentId(byId[key], byId);
1483
+ }
1484
+ return false;
1485
+ };
1379
1486
  const rank = workspaceRank(workspaceItems ?? []);
1380
- const descendantIds = descendantActiveIds(byId);
1487
+ const descendantIds = descendantActiveIds(byId, isArchived);
1381
1488
  // 第一遍:层级关系 + 显示判定(show = 自身活动 || 委托周期 || 完成提醒,单点实现避免漂移)。
1382
1489
  const rootIds = [];
1383
1490
  const childIds = new Map();
1384
1491
  const meta = new Map();
1385
1492
  for (const id of ids) {
1493
+ if (isArchived(id)) continue;
1386
1494
  const row = byId[id];
1387
1495
  if (!isRecord(row)) continue;
1388
1496
  const hasParent = isSubagentRow(row, byId);
@@ -1600,10 +1708,15 @@ function listLoadState(snapshot) {
1600
1708
  return "ready";
1601
1709
  }
1602
1710
 
1603
- /** 会话行是否为某主会话的直属子代理。 */
1711
+ /** 会话行是否为某主会话的直属子代理:需同时满足 origin 与有效父会话。 */
1604
1712
  function isSubagentRow(row, byId = {}) {
1605
1713
  const id = row?.parentId;
1606
- return id !== undefined && id !== null && isRecord(byId[id]);
1714
+ return row?.origin === "subagent" && id !== undefined && id !== null && isRecord(byId[id]);
1715
+ }
1716
+
1717
+ /** 取实际子代理的直属母会话 id;普通 fork 的 parentId 只是来源关系,不参与子代理树。 */
1718
+ function subagentParentId(row, byId = {}) {
1719
+ return isSubagentRow(row, byId) ? row.parentId : undefined;
1607
1720
  }
1608
1721
 
1609
1722
  /** 会话行是否满足自身状态的活动判定,不含后代活动继承。
@@ -1617,31 +1730,32 @@ function isOwnActiveRow(row, byId = {}) {
1617
1730
  return running || pending;
1618
1731
  }
1619
1732
 
1620
- /** 沿自身活动会话的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1733
+ /** 沿实际子代理关系的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1621
1734
  * 否则只收祖先(存在活动后代的母会话)。活动区与历史区显示判定的单点实现。 */
1622
- function lineageActiveIds(byId, includeSelf) {
1735
+ function lineageActiveIds(byId, includeSelf, isExcluded = null) {
1623
1736
  const ids = new Set();
1624
1737
  for (const [id, row] of Object.entries(byId)) {
1625
- if (!isOwnActiveRow(row, byId)) continue;
1738
+ if (isExcluded?.(id) || !isOwnActiveRow(row, byId)) continue;
1626
1739
  const seen = new Set();
1627
- let currentId = includeSelf ? id : row?.parentId;
1740
+ let currentId = includeSelf ? id : subagentParentId(row, byId);
1628
1741
  while (currentId !== undefined && currentId !== null && isRecord(byId[currentId]) && !seen.has(String(currentId))) {
1742
+ if (isExcluded?.(currentId)) break;
1629
1743
  seen.add(String(currentId));
1630
1744
  ids.add(String(currentId));
1631
- currentId = byId[currentId]?.parentId;
1745
+ currentId = subagentParentId(byId[currentId], byId);
1632
1746
  }
1633
1747
  }
1634
1748
  return ids;
1635
1749
  }
1636
1750
 
1637
- /** 沿活动会话的有效 parentId 链补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1751
+ /** 沿实际子代理关系补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1638
1752
  function activeSessionIds(byId = {}) {
1639
1753
  return lineageActiveIds(byId, true);
1640
1754
  }
1641
1755
 
1642
1756
  /** 「存在活动后代」的母会话集合(不含活动会话自身),供 buildEntries 判定委托周期(R-01-003/AC-05)。 */
1643
- function descendantActiveIds(byId = {}) {
1644
- return lineageActiveIds(byId, false);
1757
+ function descendantActiveIds(byId = {}, isExcluded = null) {
1758
+ return lineageActiveIds(byId, false, isExcluded);
1645
1759
  }
1646
1760
 
1647
1761
  /** 判断活动条目是否需要建立轮内状态订阅:以宿主 running 为准、与呈现 kind 解耦——
@@ -2083,6 +2197,43 @@ function openSession(sessions, sessionId) {
2083
2197
  }
2084
2198
  }
2085
2199
 
2200
+ /**
2201
+ * 以最小必要距离把卡片滚入窗格滚动视口;不会居中,也不会滚动外层页面。
2202
+ * 默认使用原生 smooth scroll,调用方可传 auto 适配降低动效偏好;缺少 scrollTo 或 options 不受支持时回退为同步滚动
2203
+ * (R-01-006/AC-02)。
2204
+ */
2205
+ function scrollCardIntoView(scroll, card, behavior = "smooth") {
2206
+ if (typeof scroll?.getBoundingClientRect !== "function" || typeof card?.getBoundingClientRect !== "function")
2207
+ return false;
2208
+ const viewport = scroll.getBoundingClientRect();
2209
+ const rect = card.getBoundingClientRect();
2210
+ const delta = rect.top < viewport.top
2211
+ ? rect.top - viewport.top
2212
+ : rect.bottom > viewport.bottom
2213
+ ? rect.bottom - viewport.bottom
2214
+ : 0;
2215
+ if (delta === 0) return false;
2216
+ const currentTop = Number(scroll.scrollTop);
2217
+ if (!Number.isFinite(currentTop)) return false;
2218
+ const scrollHeight = Number(scroll.scrollHeight);
2219
+ const clientHeight = Number(scroll.clientHeight);
2220
+ const maxTop = Number.isFinite(scrollHeight) && Number.isFinite(clientHeight)
2221
+ ? Math.max(0, scrollHeight - clientHeight)
2222
+ : Infinity;
2223
+ const nextTop = Math.min(maxTop, Math.max(0, currentTop + delta));
2224
+ if (!Number.isFinite(nextTop) || nextTop === currentTop) return false;
2225
+ if (typeof scroll.scrollTo === "function") {
2226
+ try {
2227
+ scroll.scrollTo({ top: nextTop, behavior });
2228
+ if (behavior !== "auto" || Number(scroll.scrollTop) !== currentTop) return true;
2229
+ } catch (error) {
2230
+ if (!(error instanceof TypeError)) throw error;
2231
+ }
2232
+ }
2233
+ scroll.scrollTop = nextTop;
2234
+ return Number(scroll.scrollTop) !== currentTop;
2235
+ }
2236
+
2086
2237
  /** 原生会话输入框(dsh-client-ui-conversation composer 的 textarea)。 */
2087
2238
  const COMPOSER_SELECTOR = "textarea[data-phase]";
2088
2239
 
@@ -2595,23 +2746,41 @@ const CSS = `
2595
2746
  /* 复审修复(C-040、C-043):状态点光晕三类同强度、仅换色相——阻塞金、完成绿与错误红光晕一致。 */
2596
2747
  /* 工作区徽标「图标+文本」双段:文件夹图标与左边栏工作区条目同源(R-01-003/AC-06);
2597
2748
  名称字号不低于 10.5px(AC-07),行高保持 14px 以维持胶囊与卡片高度。
2598
- 着色(AC-08~AC-11):核心映射提供 OKLCH hue;深色主题文字取高明度中高彩度,
2599
- 浅色主题文字取低明度中高彩度。文字直接使用调色板色,底色与描边在 OKLCH
2600
- 空间按透明度混合;前景与背景始终同色相、明度对比拉开。 */
2749
+ 着色(AC-08~AC-12):核心映射提供 12 个前景身份槽与每槽 3 个背景变体;
2750
+ 深色/浅色主题分别使用前景与背景槽位的 L/C 参数,文字使用前景调色板色,
2751
+ 底色使用独立背景源色、描边使用前景调色板色,分别在 OKLCH 空间按变体混合强度呈现;前景与背景保持同色相族。 */
2601
2752
  [data-dsh-activity-pane] .dap-workspace {
2602
2753
  width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 3px;
2603
2754
  overflow: hidden;
2604
2755
  font-size: 10.5px; line-height: 14px;
2605
- --dap-workspace-color: oklch(0.78 0.16 var(--dap-workspace-hue, 235));
2756
+ --dap-workspace-color: oklch(
2757
+ var(--dap-workspace-dark-l, 0.78)
2758
+ var(--dap-workspace-dark-c, 0.16)
2759
+ var(--dap-workspace-hue, 235)
2760
+ );
2761
+ --dap-workspace-background-color: oklch(
2762
+ var(--dap-workspace-bg-dark-l, 0.38)
2763
+ var(--dap-workspace-bg-dark-c, 0.06)
2764
+ var(--dap-workspace-hue, 235)
2765
+ );
2606
2766
  color: var(--dap-workspace-color);
2607
- background: color-mix(in oklch, var(--dap-workspace-color) 14%, transparent);
2608
- border: 1px solid color-mix(in oklch, var(--dap-workspace-color) 34%, transparent);
2767
+ background: color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-dark-mix, 31%), transparent);
2768
+ border: 1px solid color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-dark-border-mix, 52%), transparent);
2609
2769
  border-radius: 999px; padding: 0 7px;
2610
2770
  }
2611
2771
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2612
- --dap-workspace-color: oklch(0.48 0.15 var(--dap-workspace-hue, 235));
2613
- background: color-mix(in oklch, var(--dap-workspace-color) 10%, transparent);
2614
- border-color: color-mix(in oklch, var(--dap-workspace-color) 28%, transparent);
2772
+ --dap-workspace-color: oklch(
2773
+ var(--dap-workspace-light-l, 0.48)
2774
+ var(--dap-workspace-light-c, 0.15)
2775
+ var(--dap-workspace-hue, 235)
2776
+ );
2777
+ --dap-workspace-background-color: oklch(
2778
+ var(--dap-workspace-bg-light-l, 0.84)
2779
+ var(--dap-workspace-bg-light-c, 0.06)
2780
+ var(--dap-workspace-hue, 235)
2781
+ );
2782
+ background: color-mix(in oklch, var(--dap-workspace-background-color) var(--dap-workspace-bg-light-mix, 27%), transparent);
2783
+ border-color: color-mix(in oklch, var(--dap-workspace-color) var(--dap-workspace-bg-light-border-mix, 42%), transparent);
2615
2784
  }
2616
2785
  [data-dsh-activity-pane] .dap-workspace-icon { flex: none; display: inline-flex; }
2617
2786
  [data-dsh-activity-pane] .dap-workspace-icon svg { display: block; }
@@ -2649,14 +2818,10 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2649
2818
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
2650
2819
  min-width: 0; margin: 2px 0 0;
2651
2820
  }
2652
- /* 等待类型胶囊与上一轮耗时同行:时长沿用运行统计行的右缘位置,不参与等待脉冲。 */
2821
+ /* 等待类型胶囊独立同行:耗时回到统计行最右侧,不参与等待脉冲。 */
2653
2822
  [data-dsh-activity-pane] .dap-await-head {
2654
2823
  display: flex; align-items: center; gap: 8px; align-self: stretch; min-width: 0;
2655
2824
  }
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
2825
  [data-dsh-activity-pane] .dap-note-row {
2661
2826
  display: flex; align-items: center; gap: 6px; min-width: 0; align-self: stretch;
2662
2827
  }
@@ -2675,7 +2840,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
2675
2840
  color: color-mix(in srgb, currentColor 82%, transparent);
2676
2841
  background: color-mix(in srgb, currentColor 14%, transparent);
2677
2842
  }
2678
- /* 运行卡富化(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
2843
+ /* 运行卡与最近卡统计(对齐 answer-pet 卡片;MIT 参考,见 README)。 */
2679
2844
  [data-dsh-activity-pane] .dap-progress {
2680
2845
  display: flex; align-items: center; gap: 7px; min-width: 0;
2681
2846
  }
@@ -3158,16 +3323,18 @@ function apply(ctx) {
3158
3323
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
3159
3324
  let pulseSignature = "";
3160
3325
  // E2E-only deterministic seams(T-089/T-090):显式 URL fragment 最多把本页首次列表 ready 呈现
3161
- // 或正式 models RPC 延后 1s;默认路径为 0,不伪造服务响应、快照或生产时序。
3326
+ // 或正式 models RPC 延后 1s;列表延迟从首个窗格 render 开始计时,避免宿主启动耗时
3327
+ // 把 pending 观察窗口提前消耗;默认路径为 0,不伪造服务响应、快照或生产时序。
3162
3328
  const e2eParams = new URLSearchParams(window.location.hash.slice(1));
3163
3329
  const requestedListDelay = Number(e2eParams.get("dap-e2e-list-delay"));
3164
- const e2eListReadyAt = Number.isFinite(requestedListDelay) && requestedListDelay > 0
3165
- ? Date.now() + Math.min(requestedListDelay, 1_000)
3330
+ const e2eListDelayMs = Number.isFinite(requestedListDelay) && requestedListDelay > 0
3331
+ ? Math.min(requestedListDelay, 1_000)
3166
3332
  : 0;
3167
3333
  const requestedModelDelay = Number(e2eParams.get("dap-e2e-model-delay"));
3168
3334
  const e2eModelDelayMs = Number.isFinite(requestedModelDelay) && requestedModelDelay > 0
3169
3335
  ? Math.min(requestedModelDelay, 1_000)
3170
3336
  : 0;
3337
+ let e2eListReadyAt = 0;
3171
3338
  let e2eListReleaseTimer = null;
3172
3339
  const e2eModelDelayWaiters = new Map();
3173
3340
  function delayedModelCall(call) {
@@ -3188,6 +3355,9 @@ function apply(ctx) {
3188
3355
  let paneWidth = readStoredPaneWidth();
3189
3356
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
3190
3357
  let lastActivatedId = null;
3358
+ /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
3359
+ let autoScrolledCurrentId = null;
3360
+ let autoScrolledCurrentCard = null;
3191
3361
  /** 完成确认状态(R-01-002/AC-03、AC-10~AC-12):id → { lastTurnEnd, ackedAt },
3192
3362
  * 来自宿主侧 ack 通道(SSE 全量快照),随每次推送整体替换。 */
3193
3363
  const completeAcksById = new Map();
@@ -3214,7 +3384,7 @@ function apply(ctx) {
3214
3384
  const livenessById = new Map();
3215
3385
  /** 委托周期进度锚点记账:id → progressAnchor 状态(R-01-009/AC-06)。 */
3216
3386
  const progressAnchorById = new Map();
3217
- /** 订阅停止后保留最近快照,供 awaiting/recent 卡继续显示上下文。 */
3387
+ /** 订阅停止后保留最近快照与最后已知运行统计,供 awaiting/recent 卡继续显示上下文。 */
3218
3388
  const sessionDetailsById = new Map();
3219
3389
  /** 会话跳转的单一重试链;避免重复点击叠加 refresh/timer。 */
3220
3390
  const openRetryStates = new Map();
@@ -3889,7 +4059,10 @@ function apply(ctx) {
3889
4059
  const agentLabel = makeEl("span", "dap-history-label");
3890
4060
  agentLabel.textContent = "助手";
3891
4061
  agentLine.append(agentIcon, agentLabel, makeEl("span", "dap-history-separator"), makeEl("span", "dap-history-text"));
3892
- return [head, row, userLine, agentLine, makeEl("div", "dap-note")];
4062
+ const statsRow = makeEl("div", "dap-token-stats");
4063
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
4064
+ statsRow.hidden = true;
4065
+ return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];
3893
4066
  }
3894
4067
  if (kind === "awaiting") {
3895
4068
  const row = makeEl("div", "dap-row");
@@ -3903,10 +4076,13 @@ function apply(ctx) {
3903
4076
  const noteRow = makeEl("div", "dap-note-row");
3904
4077
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
3905
4078
  const awaitHead = makeEl("div", "dap-await-head");
3906
- awaitHead.append(capsule, makeEl("span", "dap-token-time"));
4079
+ awaitHead.append(capsule);
4080
+ const statsRow = makeEl("div", "dap-token-stats");
4081
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
4082
+ statsRow.hidden = true;
3907
4083
  const foot = makeEl("div", "dap-foot");
3908
4084
  foot.append(awaitHead, noteRow);
3909
- return [head, row, makeEl("div", "dap-trace"), foot];
4085
+ return [head, row, makeEl("div", "dap-trace"), statsRow, foot];
3910
4086
  }
3911
4087
  const row = makeEl("div", "dap-row");
3912
4088
  row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
@@ -4267,14 +4443,19 @@ function apply(ctx) {
4267
4443
  }
4268
4444
  }
4269
4445
 
4270
- /** 统计行渲染:运行卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
4446
+ /** 统计行渲染:运行卡、等待卡与最近卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
4271
4447
  function renderTokenStats(el, entry) {
4272
4448
  let stats = el.querySelector(".dap-token-stats");
4273
4449
  if (stats === null) {
4274
4450
  stats = makeEl("div", "dap-token-stats");
4275
4451
  stats.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
4276
4452
  stats.hidden = true;
4277
- el.append(stats);
4453
+ const foot = entry.kind === "awaiting" ? el.querySelector(".dap-foot") : null;
4454
+ if (foot !== null) el.insertBefore(stats, foot);
4455
+ else el.append(stats);
4456
+ } else if (entry.kind === "awaiting") {
4457
+ const foot = el.querySelector(".dap-foot");
4458
+ if (foot !== null && stats.nextElementSibling !== foot) el.insertBefore(stats, foot);
4278
4459
  }
4279
4460
  let mainTextEl = stats.querySelector(".dap-token-main");
4280
4461
  let timeEl = stats.querySelector(".dap-token-time");
@@ -4296,25 +4477,9 @@ function apply(ctx) {
4296
4477
  if (stats.hidden !== statsHidden) stats.hidden = statsHidden;
4297
4478
  }
4298
4479
 
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;
4480
+ /** 旧热装等待骨架清理:耗时已迁回统计行,去掉胶囊同行残留的时长节点。 */
4481
+ function removeAwaitingHeadDuration(el) {
4482
+ el.querySelector(".dap-await-head .dap-token-time")?.remove();
4318
4483
  }
4319
4484
 
4320
4485
  /** 时间线区加载指示:数据在途且尚无工作项时显示活动图标行(R-01-014/AC-02)。 */
@@ -4341,22 +4506,40 @@ function apply(ctx) {
4341
4506
  renderTrace(container, entry.timeline, { lastOnly });
4342
4507
  }
4343
4508
 
4344
- function renderCardInto(el, entry, hueByWorkspace) {
4509
+ function renderCardInto(el, entry, colorByWorkspace) {
4345
4510
  const workspaceLabel = el.querySelector(".dap-workspace");
4346
4511
  if (workspaceLabel !== null) {
4347
4512
  const workspaceText = workspaceLabel.querySelector(".dap-workspace-text");
4348
- if (entry.workspaceTitle !== "") {
4513
+ const color = colorByWorkspace.get(entry.workspaceKey);
4514
+ const foreground = color?.foreground;
4515
+ const background = color?.background;
4516
+ const colorVariables = {
4517
+ "--dap-workspace-hue": foreground?.hue,
4518
+ "--dap-workspace-dark-l": foreground?.darkL,
4519
+ "--dap-workspace-dark-c": foreground?.darkC,
4520
+ "--dap-workspace-light-l": foreground?.lightL,
4521
+ "--dap-workspace-light-c": foreground?.lightC,
4522
+ "--dap-workspace-bg-dark-l": background?.dark?.l,
4523
+ "--dap-workspace-bg-dark-c": background?.dark?.c,
4524
+ "--dap-workspace-bg-dark-mix": background?.dark?.mix,
4525
+ "--dap-workspace-bg-dark-border-mix": background?.dark?.borderMix,
4526
+ "--dap-workspace-bg-light-l": background?.light?.l,
4527
+ "--dap-workspace-bg-light-c": background?.light?.c,
4528
+ "--dap-workspace-bg-light-mix": background?.light?.mix,
4529
+ "--dap-workspace-bg-light-border-mix": background?.light?.borderMix,
4530
+ };
4531
+ if (entry.workspaceTitle !== "" && foreground !== undefined && background !== undefined) {
4349
4532
  if (workspaceText !== null) restoreTextField(workspaceText, entry.workspaceTitle);
4350
- const hue = hueByWorkspace.get(entry.workspaceKey) ?? workspaceHue(entry.workspaceKey);
4351
- const hueText = hue === null ? "" : String(hue);
4352
- if (workspaceLabel.style.getPropertyValue("--dap-workspace-hue") !== hueText) {
4353
- if (hue === null) workspaceLabel.style.removeProperty("--dap-workspace-hue");
4354
- else workspaceLabel.style.setProperty("--dap-workspace-hue", hueText);
4533
+ for (const [property, value] of Object.entries(colorVariables)) {
4534
+ const next = value === undefined ? "" : String(value);
4535
+ if (workspaceLabel.style.getPropertyValue(property) === next) continue;
4536
+ if (next === "") workspaceLabel.style.removeProperty(property);
4537
+ else workspaceLabel.style.setProperty(property, next);
4355
4538
  }
4356
4539
  workspaceLabel.removeAttribute("hidden");
4357
4540
  } else {
4358
4541
  if (workspaceText !== null) restoreTextField(workspaceText, "");
4359
- workspaceLabel.style.removeProperty("--dap-workspace-hue");
4542
+ for (const property of Object.keys(colorVariables)) workspaceLabel.style.removeProperty(property);
4360
4543
  workspaceLabel.setAttribute("hidden", "");
4361
4544
  }
4362
4545
  }
@@ -4449,12 +4632,14 @@ function apply(ctx) {
4449
4632
  restoreTextField(text, previews[i]);
4450
4633
  }
4451
4634
  }
4635
+ renderTokenStats(el, entry);
4452
4636
  }
4453
4637
 
4454
4638
  if (entry.kind === "awaiting") {
4455
4639
  const traceContainer = el.querySelector(".dap-trace");
4456
4640
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
4457
- renderAwaitingDuration(el, entry);
4641
+ removeAwaitingHeadDuration(el);
4642
+ renderTokenStats(el, entry);
4458
4643
  const confirm = el.querySelector(".dap-confirm");
4459
4644
  if (confirm !== null) {
4460
4645
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -4733,7 +4918,7 @@ function apply(ctx) {
4733
4918
 
4734
4919
  /** 渲染某一张卡片进指定列表容器(活动/历史通用)。index 是条目在卡片序列中的
4735
4920
  * 序号,offset 是容器内首个卡片前的非卡片子节点数(活动区有轨道层、历史区有段头)。 */
4736
- function renderCardIntoList(list, entry, reuseMap, index, offset, hueByWorkspace) {
4921
+ function renderCardIntoList(list, entry, reuseMap, index, offset, colorByWorkspace) {
4737
4922
  let rec = reuseMap.get(entry.id);
4738
4923
  if (rec === undefined) {
4739
4924
  const el = document.createElement("div");
@@ -4790,7 +4975,7 @@ function apply(ctx) {
4790
4975
  recentTimeText ? "," + recentTimeText : ""
4791
4976
  }`,
4792
4977
  );
4793
- renderCardInto(rec.el, entry, hueByWorkspace);
4978
+ renderCardInto(rec.el, entry, colorByWorkspace);
4794
4979
  // 只有顺序/归属真正变化时才移动 DOM:每次渲染无条件 appendChild 会把所有
4795
4980
  // 卡片瞬时移除再插回——按下/抬起之间经过的移动让浏览器取消 click;焦点卡
4796
4981
  // 被瞬时断开而失焦;悬停卡的 :hover 也随之丢失且不再补发。会话活跃期间
@@ -4800,6 +4985,37 @@ function apply(ctx) {
4800
4985
  return true;
4801
4986
  }
4802
4987
 
4988
+ /** 原生侧栏切换当前会话后,只把已呈现且未完整可见的当前卡片滚入窗格;降低动效偏好时不平滑。 */
4989
+ function ensureCurrentCardVisible(scroll, currentId) {
4990
+ const id = currentId === null || currentId === undefined ? null : String(currentId);
4991
+ if (id === null || id === "") {
4992
+ autoScrolledCurrentId = null;
4993
+ autoScrolledCurrentCard = null;
4994
+ return;
4995
+ }
4996
+ // 当前会话卡片只有在活动列表中才参与自动定位;完成确认后同一 current
4997
+ // 会话会进入历史区,不能因仍带 data-current 把窗格滚到历史卡片。
4998
+ const card = scroll?.querySelector?.(`.${LIST_CLASS} .${CARD_CLASS}[data-current]`) ?? null;
4999
+ if (
5000
+ card === null ||
5001
+ card.dataset.sessionId !== id ||
5002
+ scroll.clientHeight <= 0 ||
5003
+ card.getBoundingClientRect().height <= 0
5004
+ )
5005
+ return;
5006
+ if (autoScrolledCurrentId === id && autoScrolledCurrentCard === card) return;
5007
+ if (scrollCardIntoView(scroll, card, prefersReducedMotion() ? "auto" : "smooth")) {
5008
+ autoScrolledCurrentId = id;
5009
+ autoScrolledCurrentCard = card;
5010
+ return;
5011
+ }
5012
+ const viewport = scroll.getBoundingClientRect();
5013
+ const rect = card.getBoundingClientRect();
5014
+ if (rect.height <= viewport.height && (rect.top < viewport.top || rect.bottom > viewport.bottom)) return;
5015
+ autoScrolledCurrentId = id;
5016
+ autoScrolledCurrentCard = card;
5017
+ }
5018
+
4803
5019
  function pruneCards(reuseMap, alive) {
4804
5020
  for (const [id, rec] of reuseMap) {
4805
5021
  if (alive.has(id)) continue;
@@ -4983,6 +5199,8 @@ function apply(ctx) {
4983
5199
  recentTotal = 0;
4984
5200
  recentHasMore = false;
4985
5201
  recentAppendQueued = false;
5202
+ autoScrolledCurrentId = null;
5203
+ autoScrolledCurrentCard = null;
4986
5204
  // 旧窗格已脱离文档:其在飞平移的 transitionend 不再触发,逐元素取消避免残留。
4987
5205
  for (const el of [...shiftCleanups.keys()]) cancelShift(el);
4988
5206
  }
@@ -4991,6 +5209,7 @@ function apply(ctx) {
4991
5209
  const recentSection = pane.querySelector(`.${RECENT_CLASS}`);
4992
5210
  if (activeList === null || recentSection === null) return;
4993
5211
 
5212
+ if (e2eListReadyAt === 0 && e2eListDelayMs > 0) e2eListReadyAt = Date.now() + e2eListDelayMs;
4994
5213
  let snapshot = getSnapshot(sessions, "list");
4995
5214
  if (e2eListReadyAt > Date.now() && snapshot?.phase !== "error") {
4996
5215
  snapshot = { phase: "pending" };
@@ -5015,7 +5234,7 @@ function apply(ctx) {
5015
5234
  for (const [id, state] of progressAnchorById) {
5016
5235
  if (delegationActive(state, now)) delegatingIds.add(id);
5017
5236
  }
5018
- const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds);
5237
+ const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds);
5019
5238
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
5020
5239
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
5021
5240
  const runLikeIds = new Set(
@@ -5099,11 +5318,11 @@ function apply(ctx) {
5099
5318
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
5100
5319
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
5101
5320
  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;
5321
+ const projectionStats = statsFromProjection(projection);
5322
+ if (entry.kind === "awaiting") {
5323
+ // 等待期间冻结运行停止前的最后已知统计;刷新/无留存时回退当前列表投影。
5324
+ Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
5325
+ }
5107
5326
  // 委托周期锚点(R-01-009/AC-06):全部活动条目逐帧记账——锚点不因呈现翻转
5108
5327
  // (委托期与 awaiting 互转)或瞬时不可见而丢失,仅 dispose 时整体清除;周期内
5109
5328
  // 进度连续(含 settle 处理回合),周期外由宿主回合起点驱动(新回合归零)。
@@ -5119,13 +5338,15 @@ function apply(ctx) {
5119
5338
  progressAnchorById.set(entry.id, anchor);
5120
5339
  if (entry.kind === "running") {
5121
5340
  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 ?? {}));
5341
+ Object.assign(entry, projectionStats, { elapsedMs });
5342
+ if (detail) {
5343
+ // 列表投影短暂缺字段时,不覆盖此前已知的有效统计。
5344
+ detail.lastRuntimeStats = mergeRuntimeStats(projectionStats, detail.lastRuntimeStats);
5345
+ }
5125
5346
  // 回合进度:纯时间驱动 y = t/(t+k),半衰期每帧按最新实测速率现算,固定
5126
5347
  // k 下单调性由函数本身保证;k 变化时允许进度随速率回落而回退(委托周期
5127
5348
  // 连续、周期外回合切换归零,R-01-009/AC-06,C-014、C-044)。
5128
- entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS }) });
5349
+ entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
5129
5350
  }
5130
5351
  }
5131
5352
  // 历史区时间精化(R-01-010/AC-08、AC-09):从保留快照的 turnTimings 与已拉取的
@@ -5146,6 +5367,23 @@ function apply(ctx) {
5146
5367
  recentTotal = recentCandidates.length;
5147
5368
  const recent = recentCandidates.slice(0, recentVisibleCount);
5148
5369
  recentHasMore = recent.length < recentTotal;
5370
+ // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
5371
+ // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
5372
+ const recentDurationFallbackIds = new Set();
5373
+ for (const entry of recent) {
5374
+ const detail = sessionDetailsById.get(entry.id);
5375
+ const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
5376
+ const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
5377
+ const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
5378
+ const retained = detail?.lastRuntimeStats;
5379
+ Object.assign(entry, stats, retained ? {
5380
+ outputTokens: retained.outputTokens ?? stats.outputTokens,
5381
+ inputTokens: retained.inputTokens ?? stats.inputTokens,
5382
+ cacheHitPct: retained.cacheHitPct ?? stats.cacheHitPct,
5383
+ rateTokS: retained.rateTokS ?? stats.rateTokS,
5384
+ } : null);
5385
+ if (elapsedMs === null) recentDurationFallbackIds.add(entry.id);
5386
+ }
5149
5387
  syncRecentTimeClock(recent.length > 0);
5150
5388
  // 预览只对当前显示的 recent 卡计算(活动卡不显示预览);快照/历史引用不变时命中缓存。
5151
5389
  // 完成瞬间的窗口快照可能先有用户消息、后到 agent reply;缺任一预览时补读一次 history。
@@ -5166,7 +5404,10 @@ function apply(ctx) {
5166
5404
  entry.loadingPreviews = (!entry.userPreview || !entry.agentPreview) && historyLoads.has(entry.id);
5167
5405
  }
5168
5406
  // 补充数据读取优先级:当前会话最优先,活动区先于当前已显示历史页(区内按显示顺序)。
5169
- const durationFallbackIds = new Set(active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id));
5407
+ const durationFallbackIds = new Set([
5408
+ ...active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id),
5409
+ ...recentDurationFallbackIds,
5410
+ ]);
5170
5411
  const detailIds = [...active, ...recent].map((entry) => entry.id);
5171
5412
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
5172
5413
  loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
@@ -5191,12 +5432,32 @@ function apply(ctx) {
5191
5432
  const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
5192
5433
  const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
5193
5434
  if (sig === lastSig) return;
5194
- const hueByWorkspace = resolveWorkspaceHues(visibleEntries.map((entry) => entry.workspaceKey));
5435
+ const colorByWorkspace = resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey));
5195
5436
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
5196
5437
  const migrations = [
5197
5438
  ...movedToRecentIds(prevRenderedActiveIds, active, recent).map((id) => ({ id, from: cardsById, to: recentCardsById })),
5198
5439
  ...movedToActiveIds(prevRenderedRecentIds, active, recent).map((id) => ({ id, from: recentCardsById, to: cardsById })),
5199
5440
  ];
5441
+ // 活动卡带焦点迁入历史时,把键盘焦点交给其上一个仍在活动区的卡片;
5442
+ // 若迁移卡已在活动区首位,则退到下一张,避免焦点落到历史区或 body。
5443
+ let focusAfterMigrationId = null;
5444
+ const focusedElement = document.activeElement;
5445
+ const activeIds = new Set(active.map((entry) => String(entry.id)));
5446
+ for (const { id, from } of migrations) {
5447
+ if (from !== cardsById) continue;
5448
+ const source = from.get(id)?.el;
5449
+ if (source === undefined || !source.contains(focusedElement)) continue;
5450
+ const activeCards = [...activeList.querySelectorAll(`.${CARD_CLASS}`)];
5451
+ const sourceIndex = activeCards.indexOf(source);
5452
+ if (sourceIndex < 0) continue;
5453
+ const target = activeCards
5454
+ .slice(0, sourceIndex)
5455
+ .reverse()
5456
+ .find((card) => activeIds.has(String(card.dataset.sessionId)))
5457
+ ?? activeCards.slice(sourceIndex + 1).find((card) => activeIds.has(String(card.dataset.sessionId)));
5458
+ focusAfterMigrationId = target?.dataset.sessionId ?? null;
5459
+ break;
5460
+ }
5200
5461
  const movePlans = prepareMoveGhosts(migrations);
5201
5462
  // 迁移帧内位置受影响的其它卡片与历史区段头:DOM 写入前量取当前视觉矩形
5202
5463
  // (R-01-010/AC-10);reduced-motion 下 movePlans 为空,FLIP 量取整体跳过。
@@ -5210,7 +5471,7 @@ function apply(ctx) {
5210
5471
  const aliveActive = new Set();
5211
5472
  for (const [index, entry] of active.entries()) {
5212
5473
  try {
5213
- renderCardIntoList(activeList, entry, cardsById, index, 1, hueByWorkspace);
5474
+ renderCardIntoList(activeList, entry, cardsById, index, 1, colorByWorkspace);
5214
5475
  } catch (error) {
5215
5476
  renderOk = false;
5216
5477
  logCardRenderError(entry.id, error);
@@ -5227,7 +5488,7 @@ function apply(ctx) {
5227
5488
  // 历史区容器首个子节点是段头(.dap-recent-head),卡片从 offset 1 开始。
5228
5489
  for (const [index, entry] of recent.entries()) {
5229
5490
  try {
5230
- renderCardIntoList(recentSection, entry, recentCardsById, index, 1, hueByWorkspace);
5491
+ renderCardIntoList(recentSection, entry, recentCardsById, index, 1, colorByWorkspace);
5231
5492
  } catch (error) {
5232
5493
  renderOk = false;
5233
5494
  logCardRenderError(entry.id, error);
@@ -5246,6 +5507,8 @@ function apply(ctx) {
5246
5507
  }
5247
5508
  runMoveGhosts(movePlans);
5248
5509
  if (shiftRects !== null) runShiftAnimations(shiftRects);
5510
+ ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
5511
+ if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
5249
5512
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
5250
5513
  const headerEl = pane.querySelector(".dap-header");
5251
5514
  const recentHeadEl = recentSection?.querySelector(".dap-recent-head") ?? null;