dsh-activity-pane 0.8.0 → 0.10.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.
@@ -130,6 +130,26 @@ function clampPaneWidth(raw) {
130
130
  return Math.min(PANE_WIDTH_MAX, Math.max(PANE_WIDTH_MIN, Math.round(value)));
131
131
  }
132
132
 
133
+ /**
134
+ * 把任意输入(localStorage 字符串等)归一为合法卡片显示档位:
135
+ * 仅 'full'/'medium'/'compact' 为合法档位,其余(含缺失/非法值)回退默认中间档(R-01-021/AC-06)。
136
+ */
137
+ function normalizeDensity(raw) {
138
+ return raw === "compact" || raw === "medium" || raw === "full" ? raw : "medium";
139
+ }
140
+
141
+ /** 显示档位的循环次序:完整 → 中间 → 紧凑 → 完整(R-01-021/AC-01)。 */
142
+ const DENSITY_ORDER = ["full", "medium", "compact"];
143
+
144
+ /**
145
+ * 返回循环切换后的下一显示档位:完整 → 中间 → 紧凑 → 完整;
146
+ * 输入先经 normalizeDensity 归一,非法值视作中间档(R-01-021/AC-01)。
147
+ */
148
+ function nextDensity(value) {
149
+ const index = DENSITY_ORDER.indexOf(normalizeDensity(value));
150
+ return DENSITY_ORDER[(index + 1) % DENSITY_ORDER.length];
151
+ }
152
+
133
153
  function isRecord(value) {
134
154
  return value !== null && typeof value === "object" && !Array.isArray(value);
135
155
  }
@@ -1293,25 +1313,25 @@ function awaitBadgeStats(entries) {
1293
1313
 
1294
1314
  /** 数量标识呈现态(R-01-014/AC-06):列表在途(loading)时不冒充计数——归一为
1295
1315
  * loading 呈现(加载指示 + 加载中 aria 文案,不等待、不脉冲);否则归一为 count
1296
- * 呈现(n/m 文本 + 计数 aria 文案)。awaiting 表达「存在等待行动」——底色经
1316
+ * 呈现(n/m 文本 + 计数 aria 文案 + 悬停 tips 文案)。分子为正在运行的主会话数
1317
+ * (running = total − waiting)——随会话逐一完成递减至 0,与等待行动数互补
1318
+ * (R-01-001/AC-04、AC-05);awaiting 表达「存在等待行动」——底色经
1297
1319
  * awaitBadgeTone(错误 > 阻塞 > 完成,红/金/绿)与脉冲门控同一信号:任一等待行动
1298
1320
  * (阻塞等待、完成提醒或错误提醒)即脉冲(R-01-002/AC-06,C-037、C-043)。
1299
1321
  * blocked 入参只用于 aria 文案的计数说明,不再驱动门控。错误轴不算在途,维持计数呈现。 */
1300
1322
  function countBadgeState(listState, waiting, total, blocked = 0) {
1301
- if (listState === "loading") return { mode: "loading", text: "", ariaText: "活动会话计数加载中", awaiting: false };
1323
+ if (listState === "loading") return { mode: "loading", text: "", ariaText: "活动会话计数加载中", tip: "", awaiting: false };
1302
1324
  const awaiting = waiting > 0;
1303
1325
  const hasBlocked = blocked > 0;
1304
1326
  const doneCount = waiting - (hasBlocked ? blocked : 0);
1327
+ const running = total - waiting;
1305
1328
  return {
1306
1329
  mode: "count",
1307
- text: `${waiting}/${total}`,
1308
- ariaText: hasBlocked
1309
- ? doneCount > 0
1310
- ? `${total} 个活动会话,${blocked} 个等待你答复,${doneCount} 个已完成`
1311
- : `${total} 个活动会话,${blocked} 个等待你答复`
1312
- : awaiting
1313
- ? `${total} 个活动会话,${waiting} 个已完成`
1314
- : `${total} 个活动会话`,
1330
+ text: `${running}/${total}`,
1331
+ ariaText: `${total} 个活动会话,${running} 个正在运行`
1332
+ + (hasBlocked ? `,${blocked} 个等待你答复` : "")
1333
+ + (doneCount > 0 ? `,${doneCount} 个已完成` : ""),
1334
+ tip: `运行中的会话 ${running} / 总活动会话 ${total}`,
1315
1335
  awaiting,
1316
1336
  };
1317
1337
  }
@@ -1386,16 +1406,11 @@ function workspaceInfoForSession(sessionId, workspaceItems, byId = {}) {
1386
1406
  }
1387
1407
 
1388
1408
  /**
1389
- * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1390
- * djb2 哈希经雪崩终混(异或右移 + 乘法,把高位熵折入低位,消除 djb2 低位分布
1391
- * 聚集;每步 >>> 0 保持无符号,异或结果可能带符号位)后在避开红色警戒区的
1392
- * 色相弧 [30°,320°] 上均匀取色(30 + hash % 291),输出 [30,320] 整数。
1393
- * 同一身份恒得同一基色色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1394
- * 刷新后不变;空身份返回 null。
1409
+ * 身份字符串 → 32 位无符号哈希:djb2 经雪崩终混(见 C-029 决策记录)。
1410
+ * 异或右移 + 乘法把高位熵折入低位,消除 djb2 低位分布聚集;每步 >>> 0 保持
1411
+ * 无符号,异或结果可能带符号位。
1395
1412
  */
1396
- function workspaceHue(key) {
1397
- const text = cleanText(key);
1398
- if (!text) return null;
1413
+ function identityHash(text) {
1399
1414
  let hash = 5381;
1400
1415
  for (let i = 0; i < text.length; i += 1)
1401
1416
  hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0;
@@ -1405,8 +1420,24 @@ function workspaceHue(key) {
1405
1420
  // 乘法的乘积(最大约 5×10^18)超出 double 精确整数上限 2^53,低 32 位
1406
1421
  // 会丢失精度。
1407
1422
  hash = Math.imul(hash, 0x45d9f3b) >>> 0;
1408
- hash = (hash ^ (hash >>> 16)) >>> 0;
1409
- return 30 + (hash % 291);
1423
+ return (hash ^ (hash >>> 16)) >>> 0;
1424
+ }
1425
+
1426
+ // 避红基色色相弧 [30°,320°] 的起点与取值数(30 + hash % 291);背景变体
1427
+ // 以同一弧宽的商派生,共享常量避免两处口径漂移(R-01-003/AC-09、C-077)。
1428
+ const WORKSPACE_HUE_ARC_START = 30;
1429
+ const WORKSPACE_HUE_ARC_SIZE = 291;
1430
+
1431
+ /**
1432
+ * 工作区徽标基色色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数,
1433
+ * 在避开红色警戒区的色相弧 [30°,320°] 上均匀取色,输出 [30,320] 整数;哈希
1434
+ * 机制见 {@link identityHash}。同一身份恒得同一基色色相,与工作区列表顺序、
1435
+ * 会话状态及持久化存储无关,页面刷新后不变;空身份返回 null。
1436
+ */
1437
+ function workspaceHue(key) {
1438
+ const text = cleanText(key);
1439
+ if (!text) return null;
1440
+ return WORKSPACE_HUE_ARC_START + (identityHash(text) % WORKSPACE_HUE_ARC_SIZE);
1410
1441
  }
1411
1442
 
1412
1443
  const WORKSPACE_COLOR_SLOTS = Object.freeze([
@@ -1442,90 +1473,46 @@ const WORKSPACE_BACKGROUND_SLOTS = Object.freeze([
1442
1473
  },
1443
1474
  ].map(Object.freeze));
1444
1475
 
1445
- const WORKSPACE_PRIMARY_SLOT_COUNT = 7;
1446
- const WORKSPACE_SECONDARY_SLOT_COUNT = WORKSPACE_COLOR_SLOTS.length - WORKSPACE_PRIMARY_SLOT_COUNT;
1447
-
1448
- function workspaceSlotProbe(start, count, step) {
1449
- return Array.from({ length: count }, (_, offset) => (start + offset * step) % count);
1476
+ /** 环形色相距离(度,0–180)。 */
1477
+ function hueDistance(a, b) {
1478
+ const d = Math.abs(a - b) % 360;
1479
+ return Math.min(d, 360 - d);
1450
1480
  }
1451
1481
 
1452
1482
  /**
1453
- * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):前七个身份沿原七色
1454
- * OKLCH 主槽位分配,主槽位占满后再使用五个受控明度/色相补充槽位;每个前景槽位
1455
- * 内优先使用未用的三个背景变体;超过三十六个复合身份后按槽位使用次数均衡复用。
1483
+ * 同屏工作区复合颜色槽位消解(R-01-003/AC-08、AC-12):逐身份纯映射——每个
1484
+ * 身份独立地以其 32 位雪崩哈希派生前景与背景槽位,身份之间互不影响,创建、
1485
+ * 移除或变更其它工作区不改变既有工作区的颜色(C-077);前景取基色色相在 12 个
1486
+ * 前景槽位中环形距离最近者(平局取低槽位),背景变体取 floor(hash / 弧宽) % 3。
1487
+ * 均匀哈希使不同身份的前景碰撞概率保持在约 1/12、复合碰撞约 1/36 的最小水平。
1456
1488
  */
1457
1489
  function resolveWorkspaceColors(keys) {
1458
1490
  const identities = [...new Set((Array.isArray(keys) ? keys : []).map(cleanText).filter(Boolean))].sort();
1459
- const uses = WORKSPACE_COLOR_SLOTS.map(() => 0);
1460
- const backgroundUses = WORKSPACE_COLOR_SLOTS.map(() => WORKSPACE_BACKGROUND_SLOTS.map(() => 0));
1461
1491
  const resolved = new Map();
1462
1492
  for (const identity of identities) {
1463
- const baseHue = workspaceHue(identity);
1464
- const primaryOrder = workspaceSlotProbe(
1465
- (baseHue - 30) % WORKSPACE_PRIMARY_SLOT_COUNT,
1466
- WORKSPACE_PRIMARY_SLOT_COUNT,
1467
- 3,
1468
- );
1469
- const secondaryOrder = workspaceSlotProbe(
1470
- (baseHue - 30) % WORKSPACE_SECONDARY_SLOT_COUNT,
1471
- WORKSPACE_SECONDARY_SLOT_COUNT,
1472
- 2,
1473
- ).map((index) => WORKSPACE_PRIMARY_SLOT_COUNT + index);
1474
- const order = [...primaryOrder, ...secondaryOrder];
1475
- const backgroundOrder = workspaceSlotProbe(
1476
- (baseHue - 30) % WORKSPACE_BACKGROUND_SLOTS.length,
1477
- WORKSPACE_BACKGROUND_SLOTS.length,
1478
- 2,
1479
- );
1480
- const availableForegroundOrder = order.filter((index) => backgroundOrder.some((backgroundSlot) => backgroundUses[index][backgroundSlot] === 0));
1481
- const foregroundOrder = availableForegroundOrder.length > 0 ? availableForegroundOrder : order;
1482
- let foregroundSlot = foregroundOrder.find((index) => uses[index] === 0);
1483
- if (foregroundSlot === undefined) {
1484
- foregroundSlot = foregroundOrder.reduce(
1485
- (best, index) => (uses[index] < uses[best] ? index : best),
1486
- foregroundOrder[0],
1487
- );
1493
+ const hash = identityHash(identity);
1494
+ const baseHue = WORKSPACE_HUE_ARC_START + (hash % WORKSPACE_HUE_ARC_SIZE);
1495
+ let foregroundSlot = 0;
1496
+ let nearest = hueDistance(baseHue, WORKSPACE_COLOR_SLOTS[0].hue);
1497
+ for (let slot = 1; slot < WORKSPACE_COLOR_SLOTS.length; slot += 1) {
1498
+ const distance = hueDistance(baseHue, WORKSPACE_COLOR_SLOTS[slot].hue);
1499
+ if (distance < nearest) {
1500
+ nearest = distance;
1501
+ foregroundSlot = slot;
1502
+ }
1488
1503
  }
1489
- uses[foregroundSlot] += 1;
1490
- const backgroundUsesForForeground = backgroundUses[foregroundSlot];
1491
- const backgroundSlot = backgroundOrder.reduce(
1492
- (best, index) => (backgroundUsesForForeground[index] < backgroundUsesForForeground[best] ? index : best),
1493
- backgroundOrder[0],
1494
- );
1495
- backgroundUsesForForeground[backgroundSlot] += 1;
1496
1504
  resolved.set(identity, {
1497
1505
  foreground: WORKSPACE_COLOR_SLOTS[foregroundSlot],
1498
- background: WORKSPACE_BACKGROUND_SLOTS[backgroundSlot],
1506
+ background: WORKSPACE_BACKGROUND_SLOTS[Math.floor(hash / WORKSPACE_HUE_ARC_SIZE) % WORKSPACE_BACKGROUND_SLOTS.length],
1499
1507
  });
1500
1508
  }
1501
1509
  return resolved;
1502
1510
  }
1503
1511
 
1504
-
1505
- /** 主会话按左侧工作区顺序排序的权重;不在任何 workspace 的排在最后保持 lineage 顺序。 */
1506
- function workspaceRank(workspaceItems) {
1507
- const wsIndex = new Map();
1508
- const posIndex = new Map();
1509
- for (const workspace of workspaceItems ?? []) {
1510
- if (!isRecord(workspace)) continue;
1511
- const sessionIds = Array.isArray(workspace.sessionIds)
1512
- ? workspace.sessionIds
1513
- : [];
1514
- sessionIds.forEach((sid, p) => {
1515
- const key = String(sid);
1516
- if (!wsIndex.has(key)) {
1517
- wsIndex.set(key, wsIndex.size);
1518
- posIndex.set(key, p);
1519
- }
1520
- });
1521
- }
1522
- return (id) => {
1523
- const key = String(id);
1524
- return {
1525
- ws: wsIndex.get(key) ?? Number.MAX_SAFE_INTEGER,
1526
- pos: posIndex.get(key) ?? Number.MAX_SAFE_INTEGER,
1527
- };
1528
- };
1512
+ /** 主会话活动区排序用的最后用户指令时刻:宿主列表时间,缺失/非法视为最旧。 */
1513
+ function instructionTime(row) {
1514
+ const time = Number(row?.updatedAt);
1515
+ return Number.isFinite(time) ? time : -1;
1529
1516
  }
1530
1517
 
1531
1518
  /** 子代理的展示标题:优先目录 label,其次 displayTitle,兜底 "子任务"。 */
@@ -1588,7 +1575,6 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1588
1575
  }
1589
1576
  return false;
1590
1577
  };
1591
- const rank = workspaceRank(workspaceItems ?? []);
1592
1578
  const descendantIds = descendantActiveIds(byId, isArchived);
1593
1579
  // 第一遍:层级关系 + 显示判定(show = 自身活动 || 委托周期 || 完成提醒,单点实现避免漂移)。
1594
1580
  const rootIds = [];
@@ -1621,12 +1607,25 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1621
1607
  meta.set(id, { row, running, pending, isSub, show, done, err, descendantActive, delegating, depth: 0 });
1622
1608
  }
1623
1609
 
1624
- // 主会话按 workspace 顺序排序;未归入任何工作区的主会话保持在 lineage 中靠后。
1610
+ // 主会话分两组排序(R-01-001/AC-07):运行中主会话置顶,组内按最后一次用户指令
1611
+ // 时间(宿主列表时间)从新到旧;等待/完成组(阻塞等待、完成提醒、错误提醒)排后,
1612
+ // 组内按进入该状态的时刻(最近一次回合结束登记时刻,缺失回落宿主列表时间)从新到旧。
1613
+ // 两组相同时间均回落宿主列表出现顺序。工作区顺序不参与排序,仅承载卡片徽标与名称。
1614
+ const isRunningEntry = (id) => {
1615
+ const m = meta.get(id);
1616
+ return m !== undefined && !m.pending && (m.running || m.delegating);
1617
+ };
1618
+ const sortTime = (id) => {
1619
+ if (isRunningEntry(id)) return instructionTime(byId[id]);
1620
+ const record = completionFor(id, completions);
1621
+ const end = isRecord(record) ? Number(record.lastTurnEnd) : NaN;
1622
+ return Number.isFinite(end) ? end : instructionTime(byId[id]);
1623
+ };
1625
1624
  rootIds.sort((a, b) => {
1626
- const ra = rank(a);
1627
- const rb = rank(b);
1628
- if (ra.ws !== rb.ws) return ra.ws - rb.ws;
1629
- if (ra.pos !== rb.pos) return ra.pos - rb.pos;
1625
+ const byGroup = Number(isRunningEntry(b)) - Number(isRunningEntry(a));
1626
+ if (byGroup !== 0) return byGroup;
1627
+ const byTime = sortTime(b) - sortTime(a);
1628
+ if (byTime !== 0) return byTime;
1630
1629
  return ids.indexOf(a) - ids.indexOf(b);
1631
1630
  });
1632
1631
 
@@ -1985,58 +1984,30 @@ function durationTime(value) {
1985
1984
  return Number.isFinite(time) ? time : null;
1986
1985
  }
1987
1986
 
1988
- /** 从 history 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
1989
- function lastTurnDurationCandidateFromEvents(events) {
1990
- const starts = new Map();
1987
+ /**
1988
+ * 从 history 重放回合/等待边界事件,提取最近一个已结束回合的运行过程耗时(busy 口径:
1989
+ * 起止差值扣除回合内阻塞等待,与 R-01-020 累计口径一致,恒不大于该回合墙钟时长)。
1990
+ * 回合起止不完整、时间逆序或回合运行段不为正时忽略该回合;无可得回合返回 null。
1991
+ */
1992
+ function lastTurnBusyFromEvents(events) {
1993
+ let state = emptyTurnStats();
1991
1994
  let latest = null;
1992
1995
  for (const entry of Array.isArray(events) ? events : []) {
1993
- const event = eventOf(entry);
1994
- const turn = Number(event?.data?.turn);
1995
- const time = durationTime(event?.time);
1996
- if (!Number.isFinite(turn) || time === null) continue;
1997
- if (event.type === "turn/start") {
1998
- starts.set(turn, time);
1999
- continue;
2000
- }
2001
- if (event.type !== "turn/end") continue;
2002
- const start = starts.get(turn);
2003
- if (start === undefined || time < start) continue;
2004
- if (latest === null || time > latest.end) latest = { end: time, duration: time - start };
1996
+ const time = durationTime(eventOf(entry)?.time);
1997
+ const prev = state;
1998
+ state = applyTurnEventToStats(state, entry);
1999
+ if (prev.openTurnStart === null || state.openTurnStart !== null) continue;
2000
+ // 能走到回合闭合的必为时刻有效的 turn/end(applyTurnEventToStats 对无效时刻无效果)。
2001
+ const activeMs = (state.busyMs ?? 0) - (prev.busyMs ?? 0);
2002
+ if (activeMs <= 0) continue;
2003
+ latest = { end: time, duration: activeMs };
2005
2004
  }
2006
- return latest;
2007
- }
2008
-
2009
- /** 从 turnTimings 提取最近完整回合的结束时刻与固定耗时;两者始终来自同一回合。 */
2010
- function lastTurnDurationCandidateFromTimings(turnTimings) {
2011
- if (!(turnTimings instanceof Map)) return null;
2012
- let latest = null;
2013
- for (const timing of turnTimings.values()) {
2014
- const start = durationTime(timing?.startTime);
2015
- const end = durationTime(timing?.endTime);
2016
- if (start === null || end === null || end < start) continue;
2017
- if (latest === null || end > latest.end) latest = { end, duration: end - start };
2018
- }
2019
- return latest;
2020
- }
2021
-
2022
- /** 从 history 提取最近完整回合的固定耗时;回合起止不完整或逆序时忽略该回合。 */
2023
- function lastTurnDurationFromEvents(events) {
2024
- return lastTurnDurationCandidateFromEvents(events)?.duration ?? null;
2025
- }
2026
-
2027
- /** 从 turnTimings 提取最近完整回合的固定耗时;全部回合未结束或无效时返回 null。 */
2028
- function lastTurnDurationFromTimings(turnTimings) {
2029
- return lastTurnDurationCandidateFromTimings(turnTimings)?.duration ?? null;
2005
+ return latest?.duration ?? null;
2030
2006
  }
2031
2007
 
2032
- /** 在快照与 history 中按最近结束时刻选择同一最新完整回合的固定耗时。 */
2033
- function lastTurnDuration({ turnTimings = null, history = [] } = {}) {
2034
- const candidates = [lastTurnDurationCandidateFromTimings(turnTimings), lastTurnDurationCandidateFromEvents(history)].filter(Boolean);
2035
- let latest = null;
2036
- for (const candidate of candidates) {
2037
- if (latest === null || candidate.end > latest.end) latest = candidate;
2038
- }
2039
- return latest?.duration ?? null;
2008
+ /** 最近完整回合的固定耗时(busy 口径):从 history 事件重放派生。 */
2009
+ function lastTurnDuration({ history = [] } = {}) {
2010
+ return lastTurnBusyFromEvents(history);
2040
2011
  }
2041
2012
 
2042
2013
  /** ask_user_question 工具名:提问/计划审查等待的开启边界(tool/call)与配对结算(tool/result)。 */
@@ -2052,7 +2023,7 @@ function isBusyBoundaryEvent(type) {
2052
2023
 
2053
2024
  /** 空记账状态(无任何有效计时)。 */
2054
2025
  function emptyTurnStats() {
2055
- return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
2026
+ return { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null, watermarkTime: null };
2056
2027
  }
2057
2028
 
2058
2029
  /** 从任意来源拷贝记账状态:缺失字段归一为 null(旧记录/部分记录兼容)。 */
@@ -2064,10 +2035,14 @@ function turnStatsFrom(state) {
2064
2035
  openWaitKind: state?.openWaitKind ?? null,
2065
2036
  openWaitId: state?.openWaitId ?? null,
2066
2037
  waitedMs: state?.waitedMs ?? null,
2038
+ watermarkTime: state?.watermarkTime ?? null,
2067
2039
  };
2068
2040
  }
2069
2041
 
2070
- /** 记账状态逐字段相等(不含 watermarkSeq——宿主侧无效果事件跳过落盘的判定)。 */
2042
+ /**
2043
+ * 记账状态逐字段相等(不含 watermarkSeq——它不是状态字段,由宿主侧随写入一并落盘;
2044
+ * watermarkTime 为水位处已覆盖事件的时刻,参与相等判定以让补写该字段的收敛写入生效)。
2045
+ */
2071
2046
  function turnStatsEqual(a, b) {
2072
2047
  return (
2073
2048
  a === b ||
@@ -2076,7 +2051,8 @@ function turnStatsEqual(a, b) {
2076
2051
  (a?.openWaitStart ?? null) === (b?.openWaitStart ?? null) &&
2077
2052
  (a?.openWaitKind ?? null) === (b?.openWaitKind ?? null) &&
2078
2053
  (a?.openWaitId ?? null) === (b?.openWaitId ?? null) &&
2079
- (a?.waitedMs ?? null) === (b?.waitedMs ?? null))
2054
+ (a?.waitedMs ?? null) === (b?.waitedMs ?? null) &&
2055
+ (a?.watermarkTime ?? null) === (b?.watermarkTime ?? null))
2080
2056
  );
2081
2057
  }
2082
2058
 
@@ -2198,15 +2174,18 @@ function totalBusyDisplayMs({ busyMs = null, openTurnStart = null, waitedMs = nu
2198
2174
 
2199
2175
  /**
2200
2176
  * 回合统计记账的统一收敛(R-01-020/AC-04、AC-05):对全会话事件列表重放出下一份
2201
- * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、持久化水位非法、或水位
2202
- * 超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态全量
2203
- * 重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、总
2204
- * 耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2177
+ * 记账,宿主侧懒回填与启动扫描共用。路径选择——无记录、强制重放、持久化水位非法、
2178
+ * 或水位超前于日志最大 seq(事件 seq 空间被重编,如 dsh 0.1.5 V3 迁移)时从空状态
2179
+ * 全量重放(超前水位会把后续全部实时事件封死在守卫之外,openTurnStart 永不清空、
2180
+ * 总耗时无限增长);否则从持久化记账出发仅增量应用 `seq > watermarkSeq` 的事件。
2181
+ * 水位推进处同步记录 `watermarkTime`(水位处已覆盖事件的时刻,与实时登记按效果事件
2182
+ * 写入的口径互为保守——检测只要求该值不超过真实边界事件时刻)——宿主实时登记据此
2183
+ * 识别 seq 空间重编(低 seq 事件携带比水位更新的时刻,增量口径已失效)。
2205
2184
  * `closeOpenTurn`(宿主启动扫描):重放后仍存在的开放回合按日志最后事件时刻强制
2206
2185
  * 结算关闭——宿主重启后不存在仍在运行的回合,残留起点只会令总耗时无限增长;尾部
2207
2186
  * 未配对等待一并按同刻结算(不落到运行时长里)。
2208
2187
  */
2209
- function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2188
+ function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFresh = false } = {}) {
2210
2189
  const list = Array.isArray(records) ? records : [];
2211
2190
  let maxSeq = null;
2212
2191
  let lastTime = null;
@@ -2216,23 +2195,32 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2216
2195
  const time = durationTime(eventOf(record)?.time);
2217
2196
  if (time !== null && (lastTime === null || time > lastTime)) lastTime = time;
2218
2197
  }
2219
- const fresh = !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2198
+ const fresh = forceFresh || !isRecord(current) || !Number.isFinite(current.watermarkSeq) || (maxSeq !== null && Number(current.watermarkSeq) > maxSeq);
2220
2199
  let state = emptyTurnStats();
2221
2200
  let watermarkSeq = null;
2201
+ let watermarkTime = null;
2202
+ // 水位推进:seq 更大即前推水位;事件时刻有效才更新 watermarkTime(无效不前推)。
2203
+ const advance = (record) => {
2204
+ const seq = Number(record?.seq);
2205
+ if (!Number.isFinite(seq) || (watermarkSeq !== null && seq <= watermarkSeq)) return;
2206
+ watermarkSeq = seq;
2207
+ const time = durationTime(eventOf(record)?.time);
2208
+ if (time !== null) watermarkTime = time;
2209
+ };
2222
2210
  if (fresh) {
2223
2211
  for (const record of list) {
2224
2212
  state = applyTurnEventToStats(state, record);
2225
- const seq = Number(record?.seq);
2226
- if (Number.isFinite(seq) && (watermarkSeq === null || seq > watermarkSeq)) watermarkSeq = seq;
2213
+ advance(record);
2227
2214
  }
2228
2215
  } else {
2229
2216
  state = turnStatsFrom(current);
2217
+ watermarkTime = state.watermarkTime;
2230
2218
  watermarkSeq = Number(current.watermarkSeq);
2231
2219
  for (const record of list) {
2232
2220
  const seq = Number(record?.seq);
2233
2221
  if (!Number.isFinite(seq) || seq <= watermarkSeq) continue;
2234
2222
  state = applyTurnEventToStats(state, record);
2235
- watermarkSeq = seq;
2223
+ advance(record);
2236
2224
  }
2237
2225
  }
2238
2226
  if (closeOpenTurn && state.openTurnStart !== null) {
@@ -2245,7 +2233,7 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false } = {}) {
2245
2233
  state.openWaitId = null;
2246
2234
  state.waitedMs = null;
2247
2235
  }
2248
- return { ...state, watermarkSeq };
2236
+ return { ...state, watermarkSeq, watermarkTime };
2249
2237
  }
2250
2238
 
2251
2239
  /**
@@ -2625,6 +2613,10 @@ const STYLE_ID = "dsh-activity-pane-style";
2625
2613
  const INSTANCE_KEY = "__dshActivityPaneCleanup";
2626
2614
  /** 拖拽调宽的 localStorage 持久化键(R-01-015/AC-04)。 */
2627
2615
  const WIDTH_STORAGE_KEY = "dsh-activity-pane:width";
2616
+ /** 卡片紧凑显示的 localStorage 持久化键(R-01-021/AC-06)。 */
2617
+ const DENSITY_STORAGE_KEY = "dsh-activity-pane:density";
2618
+ /** 三档显示的可访问名称用中文标签,键为档位值(R-01-021/AC-01)。 */
2619
+ const DENSITY_LABELS = { full: "完整", medium: "中间", compact: "紧凑" };
2628
2620
  const COLLAPSED_WIDTH = 34;
2629
2621
  /** 宿主侧完成确认 API 前缀(C-030):acks 快照 / SSE 推送 / ack 写回,同源受信。 */
2630
2622
  const PANE_API_BASE = "/dsh-activity-pane/api";
@@ -2635,6 +2627,10 @@ const INDENT_PX = 16;
2635
2627
  const MOBILE_BREAKPOINT = "767px";
2636
2628
  /** 运行卡时钟:只要存在运行中会话,就以该周期刷新时长显示。 */
2637
2629
  const CLOCK_MS = 1000;
2630
+ /** 渲染与流式派生的最小合并间隔:事件密集(流式输出)时把渲染/派生频率硬顶在
2631
+ * 10Hz——事件率随宿主流式 chunk 数增长,显示粒度(秒级时长、块级时间线)无感,
2632
+ * 而渲染与 O(日志窗口) 派生不再随刷新率(移动端 120Hz)与事件率线性放大(T-127)。 */
2633
+ const SYNC_MIN_INTERVAL_MS = 100;
2638
2634
  /** 历史卡相对时间刷新周期;无需每秒重绘整列。 */
2639
2635
  const RECENT_TIME_REFRESH_MS = 60_000;
2640
2636
  /** 冷数据读取并发池上限:慢网下避免几十张卡片的 models/history 一次性挤占通道。 */
@@ -2664,25 +2660,27 @@ const CSS = `
2664
2660
  [data-dsh-activity-pane] .dap-header {
2665
2661
  display: flex;
2666
2662
  align-items: center;
2667
- gap: 8px;
2668
- padding: 10px 12px;
2669
2663
  font-size: 12px;
2670
2664
  font-weight: 700;
2671
2665
  letter-spacing: 0.02em;
2672
2666
  }
2673
- /* 标题行整体即收起控件,两端断点一致(R-01-011/AC-03、R-01-008/AC-02):指针与悬停/聚焦反馈。 */
2674
- [data-dsh-activity-pane] .dap-header { cursor: pointer; }
2675
- [data-dsh-activity-pane] .dap-header:hover,
2676
- [data-dsh-activity-pane] .dap-header:focus-visible {
2677
- background: color-mix(in srgb, currentColor 8%, transparent);
2667
+ /* 标题行拆为两部分:左侧标题区(flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦
2668
+ 高亮只覆盖标题区(R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
2669
+ 不参与标题区的悬停高亮与折叠激活。 */
2670
+ [data-dsh-activity-pane] .dap-titlebar {
2671
+ flex: 1;
2672
+ min-width: 0;
2673
+ display: flex;
2674
+ align-items: center;
2675
+ gap: 8px;
2676
+ padding: 10px 8px 10px 12px;
2677
+ cursor: pointer;
2678
2678
  }
2679
- [data-dsh-activity-pane] .dap-header:focus-visible { outline: none; }
2680
- /* 方向符号:标题行的一部分而非独立按钮,两端断点一致呈现(R-01-011/AC-03、R-01-008/AC-02)。 */
2681
- [data-dsh-activity-pane] .dap-collapse-hint {
2682
- margin-left: auto;
2683
- color: color-mix(in srgb, currentColor 45%, transparent);
2684
- font-size: 13px;
2679
+ [data-dsh-activity-pane] .dap-titlebar:hover,
2680
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible {
2681
+ background: color-mix(in srgb, currentColor 8%, transparent);
2685
2682
  }
2683
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible { outline: none; }
2686
2684
  [data-dsh-activity-pane] .dap-count {
2687
2685
  flex: none;
2688
2686
  font-size: 10px;
@@ -2753,13 +2751,13 @@ const CSS = `
2753
2751
  scrollbar-color: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent)) transparent;
2754
2752
  }
2755
2753
  }
2756
- /* 「回到顶部」悬浮图标按钮(R-01-018):窗格内右下角圆形按钮,纯图标无文字、不透明底色;
2757
- 默认 hidden,scrollTop 超阈值时由滚动监听揭隐。基类 display:flex 会压过 UA 的
2754
+ /* 「回到顶部」(R-01-018)悬浮图标按钮:右缘对齐、28px 圆形、不透明底色。
2755
+ 默认 hidden,scrollTop 超阈值时由滚动监听揭隐;基类 display:flex 会压过 UA 的
2758
2756
  [hidden] 规则,故显式补 [hidden] 隐藏。 */
2759
2757
  [data-dsh-activity-pane] .dap-top {
2760
2758
  position: absolute;
2761
- bottom: 12px;
2762
2759
  right: 12px;
2760
+ bottom: 12px;
2763
2761
  z-index: 6;
2764
2762
  display: flex;
2765
2763
  align-items: center;
@@ -2778,6 +2776,109 @@ const CSS = `
2778
2776
  [data-dsh-activity-pane] .dap-top:focus-visible {
2779
2777
  background: #262932;
2780
2778
  }
2779
+ /* 标题行右侧工具按钮区(R-01-021/AC-05):固定于行尾、常显档位切换按钮,未来新
2780
+ 工具按钮统一加入此区;宽度不随悬停态变化,收起方向图标的显隐不挤动本区按钮;
2781
+ 左缘 padding 12px 与标题区保持充分间隔。收起方向图标(R-01-011/AC-07)是标题区
2782
+ 的行尾提示:常态宽度为 0 不占位,鼠标悬停或键盘聚焦标题区时在标题区最右端即时
2783
+ 显现(无过渡动画),离开即隐藏。 */
2784
+ [data-dsh-activity-pane] .dap-tools {
2785
+ display: flex;
2786
+ align-items: center;
2787
+ gap: 4px;
2788
+ padding: 10px 12px;
2789
+ }
2790
+ [data-dsh-activity-pane] .dap-density {
2791
+ display: flex;
2792
+ align-items: center;
2793
+ justify-content: center;
2794
+ width: 22px;
2795
+ height: 22px;
2796
+ padding: 0;
2797
+ border: 1px solid rgba(255, 255, 255, 0.14);
2798
+ border-radius: 999px;
2799
+ background: #1d1f25;
2800
+ color: inherit;
2801
+ cursor: pointer;
2802
+ }
2803
+ [data-dsh-activity-pane] .dap-density:hover,
2804
+ [data-dsh-activity-pane] .dap-density:focus-visible {
2805
+ background: #262932;
2806
+ }
2807
+ /* 仓库入口无描边(T-139):视觉强度弱于带描边的档位切换按钮,仅以不透明底色圆形呈现。 */
2808
+ [data-dsh-activity-pane] .dap-repo {
2809
+ display: flex;
2810
+ align-items: center;
2811
+ justify-content: center;
2812
+ width: 22px;
2813
+ height: 22px;
2814
+ border-radius: 999px;
2815
+ background: #1d1f25;
2816
+ color: inherit;
2817
+ cursor: pointer;
2818
+ text-decoration: none;
2819
+ }
2820
+ [data-dsh-activity-pane] .dap-repo:hover,
2821
+ [data-dsh-activity-pane] .dap-repo:focus-visible {
2822
+ background: #262932;
2823
+ }
2824
+ [data-dsh-activity-pane] .dap-collapse-hint {
2825
+ margin-left: auto;
2826
+ display: flex;
2827
+ align-items: center;
2828
+ justify-content: center;
2829
+ width: 0;
2830
+ height: 22px;
2831
+ opacity: 0;
2832
+ overflow: hidden;
2833
+ }
2834
+ [data-dsh-activity-pane] .dap-titlebar:hover .dap-collapse-hint,
2835
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible .dap-collapse-hint {
2836
+ width: 22px;
2837
+ opacity: 1;
2838
+ }
2839
+ /* 卡片显示档位(R-01-021):中间档保留标题行、工作区徽标行、等待末行与最近卡
2840
+ 消息预览行、经渲染层 lastOnly 单行渲染时间线(仅最新一行,AC-08);完成提醒卡
2841
+ 末行在中间档收合为单行(见下方 data-wait="done" 作用域规则);紧凑档在
2842
+ 中间档基础上再隐藏工作区徽标行、时间线末行、等待末行与消息预览行,仅保留
2843
+ 标题行(AC-02)——激活跳转逻辑不感知档位,渲染签名含显示档位分量(档位切换
2844
+ 经 queueSync 触发一轮重渲染)(R-01-021/AC-04)。 */
2845
+ [data-dsh-activity-pane][data-density="medium"] .dap-card :is(
2846
+ .dap-progress,
2847
+ .dap-token-stats
2848
+ ) {
2849
+ display: none;
2850
+ }
2851
+ [data-dsh-activity-pane][data-density="compact"] .dap-card :is(
2852
+ .dap-card-head,
2853
+ .dap-trace,
2854
+ .dap-subtrace,
2855
+ .dap-progress,
2856
+ .dap-token-stats,
2857
+ .dap-foot,
2858
+ .dap-history-line,
2859
+ .dap-note
2860
+ ) {
2861
+ display: none;
2862
+ }
2863
+ /* 中间档完成提醒卡末行收合为单行(R-01-021/AC-08,东家反馈):末行两行内容
2864
+ (「已完成」胶囊行 +「继续对话,或移入历史」与「移入历史」按钮行)冗余,收合为
2865
+ 单行——「已完成」胶囊居左、按钮 margin-left:auto 居右、正文不再显示;两段包裹层
2866
+ 以 display: contents 释放为同行 flex 项。选择器以卡片根 data-wait="done" 作用域,
2867
+ 阻塞/错误提醒卡与完整呈现档的两行结构不受影响,紧凑档仍整体隐藏末行。 */
2868
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-foot {
2869
+ flex-direction: row;
2870
+ align-items: center;
2871
+ gap: 6px;
2872
+ }
2873
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] :is(.dap-await-head, .dap-note-row) {
2874
+ display: contents;
2875
+ }
2876
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-note {
2877
+ display: none;
2878
+ }
2879
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-confirm {
2880
+ margin-left: auto;
2881
+ }
2781
2882
  [data-dsh-activity-pane] .dap-list {
2782
2883
  display: flex;
2783
2884
  flex-direction: column;
@@ -3035,9 +3136,11 @@ const CSS = `
3035
3136
  box-shadow: none;
3036
3137
  animation: none;
3037
3138
  }
3139
+ /* 会话卡标题(活动卡、子代理卡与最近卡共用 .dap-title)统一常规字重,不加粗
3140
+ (R-01-013/AC-09 及东家 2026-09-11 视觉反馈);无按卡类的字重覆盖。 */
3038
3141
  [data-dsh-activity-pane] .dap-title {
3039
3142
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
3040
- white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 700;
3143
+ white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 400;
3041
3144
  }
3042
3145
  /* 标题行最右侧的累计运行时长(R-01-020/AC-01):固定占位不参与标题挤压,
3043
3146
  标题过长时以自身省略号让位;色调弱于标题,不与状态点抢视觉。 */
@@ -3046,10 +3149,6 @@ const CSS = `
3046
3149
  color: #8a94a3; white-space: nowrap;
3047
3150
  }
3048
3151
  [data-dsh-activity-pane] .dap-total-time[hidden] { display: none; }
3049
- /* 最近历史卡标题降为常规字重:历史区不抢占视觉强调(R-01-013/AC-09)。 */
3050
- [data-dsh-activity-pane] .dap-card[data-kind="recent"] .dap-title {
3051
- font-weight: 400;
3052
- }
3053
3152
  /* 等待卡末行首行「类型胶囊」(R-01-002/AC-01、AC-02、AC-09、AC-13,C-043):圆底类型
3054
3153
  图标 + 类型文字,色相随等待类别(--dap-wait-color)——阻塞金/完成绿/错误红;
3055
3154
  胶囊为行内元素不自占满宽,随文字内容收缩。 */
@@ -3341,17 +3440,30 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3341
3440
  [data-dsh-activity-pane] .dap-fill {
3342
3441
  position: absolute; inset: 0 auto 0 0; width: 0%;
3343
3442
  border-radius: 6px;
3344
- background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);
3345
- background-size: 200% 100%;
3443
+ overflow: hidden;
3346
3444
  box-shadow: 0 0 7px rgba(88, 201, 143, 0.5);
3347
3445
  transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1);
3348
3446
  /* 进度条仅存于运行卡骨架:会话运行全程持续向右滚动条纹,作为活动标志
3349
3447
  (对齐 answer-pet 的 ap-stripes,R-01-009/AC-08)。 */
3448
+ /* 条带载体 ::after 与滚动动画分离(T-128):background-position 不可合成,
3449
+ 每帧重绘;改由伪元素 transform 平移承载滚动,fill 只保留 width 过渡与
3450
+ 裁切,滚动帧全程合成器驱动。 */
3451
+ }
3452
+ [data-dsh-activity-pane] .dap-fill::after {
3453
+ content: "";
3454
+ position: absolute;
3455
+ inset: 0 auto 0 0;
3456
+ /* 覆盖 fill 宽度 + 一次位移量:平移全程右缘不落后于 fill 右缘,无缝循环。 */
3457
+ width: calc(100% + 40px);
3458
+ /* 周期 20px(色带 10px)与原 background-position 实现一致——px 色标不受
3459
+ background-size 拉伸(T-128 双轴实测);translateX(-40px) 恰为 2 个周期,
3460
+ 无缝且速度 40px/0.8s 与原实现一致。 */
3461
+ background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);
3350
3462
  animation: dap-stripes 0.8s linear infinite;
3351
3463
  }
3352
3464
  @keyframes dap-stripes {
3353
- from { background-position: 0 0; }
3354
- to { background-position: 40px 0; }
3465
+ from { transform: translateX(-40px); }
3466
+ to { transform: translateX(0); }
3355
3467
  }
3356
3468
  @media (prefers-reduced-motion: reduce) {
3357
3469
  /* answer-pet 保留状态脉冲/进度条纹;仅关闭宽度过渡,避免状态反馈消失。 */
@@ -3461,6 +3573,11 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3461
3573
  touch-action: none;
3462
3574
  }
3463
3575
  [data-dsh-activity-pane][data-open="true"] { transform: translateX(0); }
3576
+ /* 屏外休眠(T-127):抽屉关闭(含初始未开,data-open 缺省视同关闭)时子树整体
3577
+ 跳过渲染——无限动画与样式失效不再产生渲染开销;布局状态保留,scrollTop 不归零
3578
+ (区别于 display:none,T-027);打开瞬间恢复渲染,滑入过渡不变。开关与遮罩挂
3579
+ body,不落本规则,浮动开关徽标脉冲(R-01-002/AC-06、AC-07)照常。 */
3580
+ [data-dsh-activity-pane]:not([data-open="true"]) { content-visibility: hidden; }
3464
3581
  .dap-backdrop[data-drawer-open] { display: block; }
3465
3582
  .dap-toggle { display: flex; }
3466
3583
  .dap-toggle[data-drawer-open] { display: none; }
@@ -3554,13 +3671,23 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
3554
3671
  body:not([data-ds-dark-theme]) .dap-toggle {
3555
3672
  background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
3556
3673
  }
3557
- /* 「回到顶部」图标按钮浅色覆盖:不透明层-2 底色与外壳描边别名(R-01-018/AC-05)。 */
3558
- body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top {
3674
+ /* 「回到顶部」与标题行工具区档位/仓库入口按钮的浅色覆盖:不透明层-2 底色与外壳描边别名
3675
+ (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
3676
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3677
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density,
3678
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo {
3559
3679
  background: var(--dsw-alias-bg-layer-2, #ffffff);
3680
+ }
3681
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3682
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density {
3560
3683
  border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
3561
3684
  }
3562
3685
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:hover,
3563
- body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:focus-visible {
3686
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:focus-visible,
3687
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density:hover,
3688
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density:focus-visible,
3689
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo:hover,
3690
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo:focus-visible {
3564
3691
  background: var(--dsw-alias-bg-layer-3, #eceef1);
3565
3692
  }
3566
3693
  `;
@@ -3636,6 +3763,22 @@ function writeStoredPaneWidth(width) {
3636
3763
  } catch {}
3637
3764
  }
3638
3765
 
3766
+ /** 读取持久化卡片显示档位:缺失/非法值经 normalizeDensity 归一为默认中间档;
3767
+ * localStorage 不可用(隐私模式)静默回退中间档(R-01-021/AC-06)。 */
3768
+ function readStoredDensity() {
3769
+ try {
3770
+ return normalizeDensity(window.localStorage.getItem(DENSITY_STORAGE_KEY));
3771
+ } catch {
3772
+ return "medium";
3773
+ }
3774
+ }
3775
+ /** 切换时持久化卡片显示档位;localStorage 不可用时静默跳过(R-01-021/AC-06)。 */
3776
+ function writeStoredDensity(value) {
3777
+ try {
3778
+ window.localStorage.setItem(DENSITY_STORAGE_KEY, value);
3779
+ } catch {}
3780
+ }
3781
+
3639
3782
  function apply(ctx) {
3640
3783
  const previousCleanup = document[INSTANCE_KEY] ?? globalThis[INSTANCE_KEY];
3641
3784
  if (typeof previousCleanup === "function") previousCleanup();
@@ -3653,6 +3796,10 @@ function apply(ctx) {
3653
3796
  let clockTimer = null;
3654
3797
  let recentTimeTimer = null;
3655
3798
  let syncScheduled = false;
3799
+ /** 渲染节流状态(T-127):lastSyncAt 取 0 保证首轮立即渲染;syncThrottleTimer 为
3800
+ * 节流窗口尾的在途 timer,交付或卸载时清空。 */
3801
+ let lastSyncAt = 0;
3802
+ let syncThrottleTimer = null;
3656
3803
  let lastSig = "";
3657
3804
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
3658
3805
  let pulseSignature = "";
@@ -3687,6 +3834,12 @@ function apply(ctx) {
3687
3834
  let collapsed = false;
3688
3835
  /** 当前桌面列宽:启动时从 localStorage 恢复,拖拽实时更新,重挂载后保留(R-01-015)。 */
3689
3836
  let paneWidth = readStoredPaneWidth();
3837
+ /** 卡片显示档位(full/medium/compact):启动时从 localStorage 恢复,切换实时更新,
3838
+ * 重挂载后保留(R-01-021/AC-06)。 */
3839
+ let densityLevel = readStoredDensity();
3840
+ /** 待执行的锚定补偿:档位切换时登记当前选中卡片顶部的视口相对位置,本轮渲染
3841
+ * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。 */
3842
+ let pendingDensityAnchor = null;
3690
3843
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
3691
3844
  let lastActivatedId = null;
3692
3845
  /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
@@ -3854,13 +4007,23 @@ function apply(ctx) {
3854
4007
  if (changed) notifyLayoutChange();
3855
4008
  }
3856
4009
 
4010
+ function deliverSync() {
4011
+ syncScheduled = false;
4012
+ syncThrottleTimer = null;
4013
+ if (disposed) return;
4014
+ lastSyncAt = Date.now();
4015
+ render();
4016
+ }
4017
+
4018
+ /** 渲染入口合帧 + 节流(T-127):距上次渲染落点不足 SYNC_MIN_INTERVAL_MS 时,
4019
+ * 本轮请求合并到窗口尾由 timer 交付(在途请求只此一个);达到间隔走原 rAF
4020
+ * 合帧立即渲染。空闲期无事件即无排队,不引入常驻唤醒。 */
3857
4021
  function queueSync() {
3858
4022
  if (disposed || syncScheduled) return;
3859
4023
  syncScheduled = true;
3860
- schedule(() => {
3861
- syncScheduled = false;
3862
- if (!disposed) render();
3863
- });
4024
+ const wait = SYNC_MIN_INTERVAL_MS - (Date.now() - lastSyncAt);
4025
+ if (wait > 0) syncThrottleTimer = setTimeout(deliverSync, wait);
4026
+ else schedule(deliverSync);
3864
4027
  }
3865
4028
 
3866
4029
  // ---- 完成确认通道(R-01-002/AC-10~AC-12、R-01-010/AC-06,C-030) ----
@@ -4050,6 +4213,36 @@ function apply(ctx) {
4050
4213
  window.addEventListener("pageshow", onBusyPageShow);
4051
4214
  connectBusyStream();
4052
4215
 
4216
+ // ---- 回到前台渲染管线自愈(R-01-001/AC-03 回归修复) ----
4217
+ // iOS 后台挂起会丢弃在途 setTimeout:queueSync 的节流窗口尾与流式派生窗口若在
4218
+ // 挂起前排队,恢复后永不交付——syncScheduled/logDeriveTimer 永久滞留,此后一切
4219
+ // 更新(store 订阅推送、SSE 快照、时钟 tick)都被 queueSync 早退吞掉,窗格停留
4220
+ // 在挂起前状态(会话已完成仍显示运行中)。回前台时无条件清掉两类在途 timer 并
4221
+ // 立即交付一轮渲染(rAF 路径挂起安全),与 acks/busy 通道回前台重建同模式。
4222
+ function resumeRenderPipeline() {
4223
+ if (disposed) return;
4224
+ if (syncThrottleTimer !== null) {
4225
+ clearTimeout(syncThrottleTimer);
4226
+ syncThrottleTimer = null;
4227
+ }
4228
+ syncScheduled = false;
4229
+ for (const detail of sessionDetailsById.values()) {
4230
+ if (detail.logDeriveTimer && detail.logDeriveFlush) {
4231
+ clearTimeout(detail.logDeriveTimer);
4232
+ detail.logDeriveFlush();
4233
+ }
4234
+ }
4235
+ schedule(deliverSync);
4236
+ }
4237
+ const onSyncVisibilityResume = () => {
4238
+ if (document.visibilityState === "visible") resumeRenderPipeline();
4239
+ };
4240
+ const onSyncPageShow = (event) => {
4241
+ if (event?.persisted === true) resumeRenderPipeline();
4242
+ };
4243
+ document.addEventListener("visibilitychange", onSyncVisibilityResume);
4244
+ window.addEventListener("pageshow", onSyncPageShow);
4245
+
4053
4246
  function remoteValue(response) {
4054
4247
  if (response?.ok === true) return response.value;
4055
4248
  throw response?.error ?? new Error("remote request failed");
@@ -4188,7 +4381,7 @@ function apply(ctx) {
4188
4381
  });
4189
4382
  }
4190
4383
  }
4191
- captureSessionLog(id, { subagent, cwd: byId[id]?.cwd ?? "" });
4384
+ captureSessionLog(id, { cwd: byId[id]?.cwd ?? "" });
4192
4385
  // 长会话深读兜底(R-01-013/AC-03):最近卡预览/子代理溯源不在尾页日志窗口内
4193
4386
  // 且宿主标记 hasMore 时,按 beforeSeq 向前回溯翻页(默认无页数上限)。
4194
4387
  const windowEntries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
@@ -4264,7 +4457,7 @@ function apply(ctx) {
4264
4457
  /** 绑定会话并水合事件源(dsh 0.1.5 起会话内容经 eventSource 流式下发:打开即收
4265
4458
  * 完整日志窗口 + 实时尾,原生 Conversation 同源)。冷会话补一次 open(),日志窗口
4266
4459
  * 快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。 */
4267
- function captureSessionLog(id, { subagent, cwd }) {
4460
+ function captureSessionLog(id, { cwd } = {}) {
4268
4461
  const detail = sessionDetailsById.get(id) ?? {};
4269
4462
  sessionDetailsById.set(id, detail);
4270
4463
  let session = null;
@@ -4285,10 +4478,7 @@ function apply(ctx) {
4285
4478
  session.eventSource.subscribe(() => {
4286
4479
  if (disposed) return;
4287
4480
  const listSnap = getSnapshot(sessions, "list");
4288
- captureSessionLog(id, {
4289
- subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}),
4290
- cwd: listSnap?.byId?.[id]?.cwd ?? "",
4291
- });
4481
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
4292
4482
  queueSync();
4293
4483
  }),
4294
4484
  );
@@ -4314,8 +4504,30 @@ function apply(ctx) {
4314
4504
  const log = session.eventSource?.getSnapshot?.() ?? null;
4315
4505
  if (log === detail.log) return;
4316
4506
  detail.log = log;
4317
- const entries = Array.isArray(log?.entries) ? log.entries : [];
4318
- applyLogEvents(id, detail, entries, { subagent, cwd });
4507
+ // 流式派生合并(T-127):事件到达只更新引用并标脏,applyLogEvents O(日志窗口)
4508
+ // 全量折叠/预览/模型提取合并进 SYNC_MIN_INTERVAL_MS 窗口执行——事件率与派生成本
4509
+ // 解耦,消化时读到的即最新窗口。subagent/cwd 在回调内经 list 快照现取(timer 在途
4510
+ // 期间行属性可能突变,建窗入参不代表消化时刻;与 logSourceSubs 回调同模式),
4511
+ // cwd 在快照不可得时回退建窗入参(subagent 现取即权威,无建窗回退)。
4512
+ // 深翻路径为一次性同步调用,不经本窗口。交付体同时挂在 detail.logDeriveFlush
4513
+ // 上:回前台自愈据此补交付被挂起丢弃的窗口(resumeRenderPipeline)。
4514
+ if (!detail.logDeriveTimer && typeof setTimeout === "function") {
4515
+ const flushDerive = () => {
4516
+ detail.logDeriveTimer = null;
4517
+ detail.logDeriveFlush = null;
4518
+ if (disposed) return;
4519
+ const listSnap = getSnapshot(sessions, "list");
4520
+ const entries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
4521
+ applyLogEvents(id, detail, entries, {
4522
+ subagent: isSubagentRow(listSnap?.byId?.[id], listSnap?.byId ?? {}),
4523
+ cwd: listSnap?.byId?.[id]?.cwd ?? cwd,
4524
+ });
4525
+ queueSync();
4526
+ };
4527
+ detail.logDeriveFlush = flushDerive;
4528
+ detail.logDeriveTimer = setTimeout(flushDerive, SYNC_MIN_INTERVAL_MS);
4529
+ }
4530
+ queueSync();
4319
4531
  }
4320
4532
 
4321
4533
  /** 部署级模型目录一次性读取(R-01-012/AC-01):dsh 0.1.5 起 per-session models
@@ -4393,7 +4605,7 @@ function apply(ctx) {
4393
4605
  return true;
4394
4606
  }
4395
4607
  function bindPaneControls(pane) {
4396
- const header = pane.querySelector(".dap-header");
4608
+ const header = pane.querySelector(".dap-titlebar");
4397
4609
  const rail = pane.querySelector(".dap-rail");
4398
4610
  const resize = pane.querySelector(".dap-resize");
4399
4611
  const scroll = pane.querySelector(".dap-scroll");
@@ -4495,6 +4707,37 @@ function apply(ctx) {
4495
4707
  const onTopClick = () => {
4496
4708
  scroll?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
4497
4709
  };
4710
+ // 显示档位循环切换(R-01-021/AC-01):完整→中间→紧凑→完整,形态写窗格根属性
4711
+ // 驱动纯 CSS 呈现,持久化于 localStorage(AC-06),会话状态变化不触碰已选档位(AC-07)。
4712
+ const densityBtn = pane.querySelector(".dap-density");
4713
+ const applyDensity = () => {
4714
+ pane.setAttribute("data-density", densityLevel);
4715
+ if (densityBtn !== null) {
4716
+ const next = nextDensity(densityLevel);
4717
+ densityBtn.setAttribute("aria-label", `切换为${DENSITY_LABELS[next]}显示`);
4718
+ densityBtn.title = `${DENSITY_LABELS[next]}显示`;
4719
+ }
4720
+ };
4721
+ const onDensityClick = () => {
4722
+ // 滚动锚定(R-01-021/AC-01):记录切换前当前选中卡片顶部相对滚动视口的
4723
+ // 位置,档位翻转后把 scrollTop 补偿回该相对位置,使卡片顶部在屏幕上不动。
4724
+ const scrollEl = scroll ?? pane.querySelector(".dap-scroll");
4725
+ const currentCard = scrollEl?.querySelector(".dap-card[data-current]") ?? null;
4726
+ const viewportTop = scrollEl?.getBoundingClientRect().top ?? 0;
4727
+ const anchorTop = currentCard ? currentCard.getBoundingClientRect().top - viewportTop : null;
4728
+ densityLevel = nextDensity(densityLevel);
4729
+ writeStoredDensity(densityLevel);
4730
+ applyDensity();
4731
+ // 档位已纳入渲染签名:触发一轮同步让时间线按新档位以 lastOnly 重建;
4732
+ // 锚定补偿在本轮渲染提交后执行,此时量测才含新行高
4733
+ //(R-01-021/AC-01、AC-08)。
4734
+ pendingDensityAnchor = currentCard && anchorTop !== null ? anchorTop : null;
4735
+ queueSync();
4736
+ };
4737
+ // 档位按钮位于标题行右侧的工具区(标题区折叠控件的兄弟节点),激活天然不会
4738
+ // 冒泡为标题区折叠,无需额外阻断。
4739
+ densityBtn?.addEventListener("click", onDensityClick);
4740
+ applyDensity();
4498
4741
  header?.addEventListener("click", onHeaderActivate);
4499
4742
  header?.addEventListener("keydown", onHeaderKeydown);
4500
4743
  rail?.addEventListener("click", onRailClick);
@@ -4515,6 +4758,7 @@ function apply(ctx) {
4515
4758
  recentMore?.removeEventListener("click", onRecentMoreClick);
4516
4759
  if (scrollHideTimer !== null) clearTimeout(scrollHideTimer);
4517
4760
  topBtn?.removeEventListener("click", onTopClick);
4761
+ densityBtn?.removeEventListener("click", onDensityClick);
4518
4762
  resize?.removeEventListener("pointerdown", onResizeDown);
4519
4763
  };
4520
4764
  }
@@ -4534,10 +4778,16 @@ function apply(ctx) {
4534
4778
  pane.className = PANE_CLASS;
4535
4779
  center.insertBefore(pane, seat);
4536
4780
  pane.innerHTML = `
4537
- <div class="dap-header" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4538
- <span>活动会话</span>
4539
- <span class="dap-count" role="status" aria-live="polite"></span>
4540
- <span class="dap-collapse-hint" aria-hidden="true">«</span>
4781
+ <div class="dap-header">
4782
+ <span class="dap-titlebar" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4783
+ <span>活动会话</span>
4784
+ <span class="dap-count" role="status" aria-live="polite"></span>
4785
+ <span class="dap-collapse-hint" aria-hidden="true"></span>
4786
+ </span>
4787
+ <span class="dap-tools">
4788
+ <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题"></a>
4789
+ <button class="dap-density" type="button" aria-label="切换为紧凑显示" title="紧凑显示"></button>
4790
+ </span>
4541
4791
  </div>
4542
4792
  <div class="dap-scroll">
4543
4793
  <div class="dap-list" tabindex="-1"><div class="dap-tracks" aria-hidden="true"></div></div>
@@ -4556,6 +4806,13 @@ function apply(ctx) {
4556
4806
  pane.style.setProperty("--dap-width", `${paneWidth}px`);
4557
4807
  // 「回到顶部」按钮为纯图标呈现(R-01-018/AC-05):骨架无文字,图标在创建时注入。
4558
4808
  pane.querySelector(".dap-top").append(createTopIcon());
4809
+ // 紧凑显示切换按钮常显于标题行右侧工具区(R-01-021/AC-05);切换时以 data-density
4810
+ // 驱动纯 CSS 呈现,骨架重建后由 bindPaneControls 的 applyDensity 恢复形态。
4811
+ pane.querySelector(".dap-density").append(createDensityIcon());
4812
+ // 仓库入口常显于标题行右侧工具区(R-01-022/AC-01);图标在创建时注入。
4813
+ pane.querySelector(".dap-repo").append(createRepoIcon());
4814
+ // 收起方向图标为标题行悬停/聚焦的可见性提示(R-01-011/AC-07),图标在创建时注入。
4815
+ pane.querySelector(".dap-collapse-hint").append(createCollapseIcon());
4559
4816
  }
4560
4817
  if (pane !== boundPane) {
4561
4818
  unbindPaneControls?.();
@@ -4728,6 +4985,50 @@ function apply(ctx) {
4728
4985
  });
4729
4986
  }
4730
4987
 
4988
+ /** 紧凑显示切换按钮的「多行收拢为单行」密度图标:上下两个指向中线的箭头夹一条
4989
+ * 标题行横线,与「回到顶部」的单向箭头、标题行的收起方向图标均可区分
4990
+ * (canonical 图标集无现成字形,14 盒 stroke 风格与 createTopIcon 一致,R-01-021/AC-05)。 */
4991
+ function createDensityIcon() {
4992
+ return createInlineIcon({
4993
+ viewBox: "0 0 14 14",
4994
+ width: 14,
4995
+ height: 14,
4996
+ parts: [
4997
+ { attrs: { d: "m3.5 4.5 3.5-3.5 3.5 3.5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
4998
+ { attrs: { d: "M3 7h8", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
4999
+ { attrs: { d: "m3.5 9.5 3.5 3.5 3.5-3.5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5000
+ ],
5001
+ });
5002
+ }
5003
+
5004
+ /** 标题行的「收起」方向图标(R-01-011/AC-07):左侧竖杠 + 向左箭头指向竖杠,
5005
+ * 表达窗格收缩到左侧;随 .dap-collapse-hint 在标题行悬停/聚焦时显现。 */
5006
+ function createCollapseIcon() {
5007
+ return createInlineIcon({
5008
+ viewBox: "0 0 14 14",
5009
+ width: 14,
5010
+ height: 14,
5011
+ parts: [
5012
+ { attrs: { d: "M3 2.5v9", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5013
+ { attrs: { d: "M11.5 7H5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5014
+ { attrs: { d: "M8.25 3.75 5 7l3.25 3.25", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5015
+ ],
5016
+ });
5017
+ }
5018
+
5019
+ /** 标题行工具区的仓库入口图标(R-01-022/AC-01):GitHub Octicon `mark-github` 字形
5020
+ * (MIT 许可,Primer Octicons),与工具区既有图标同用 14px 字形盒。 */
5021
+ function createRepoIcon() {
5022
+ return createInlineIcon({
5023
+ viewBox: "0 0 16 16",
5024
+ width: 14,
5025
+ height: 14,
5026
+ parts: [
5027
+ { attrs: { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.28.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z", fill: "currentColor" } },
5028
+ ],
5029
+ });
5030
+ }
5031
+
4731
5032
  function createUserIcon() {
4732
5033
  return createInlineIcon({
4733
5034
  viewBox: "0 0 16 16",
@@ -5055,13 +5356,14 @@ function apply(ctx) {
5055
5356
  }
5056
5357
  }
5057
5358
 
5058
- /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):快照/历史引用不变即命中缓存。 */
5059
- function memoTurnDuration(detail, detailSnapshot) {
5359
+ /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):history 引用
5360
+ * 不变即命中缓存。busy 口径(起止差值扣除回合内阻塞等待),与标题行总耗时同口径,
5361
+ * 保证恒不大于累计值(R-01-020)。 */
5362
+ function memoTurnDuration(detail) {
5060
5363
  const history = detail.history ?? null;
5061
- if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
5062
- detail.memoTurnDurationSnapshotOf = detailSnapshot;
5364
+ if (detail.memoTurnDurationHistoryOf !== history) {
5063
5365
  detail.memoTurnDurationHistoryOf = history;
5064
- detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
5366
+ detail.memoTurnDuration = lastTurnDuration({ history });
5065
5367
  }
5066
5368
  return detail.memoTurnDuration ?? null;
5067
5369
  }
@@ -5229,7 +5531,7 @@ function apply(ctx) {
5229
5531
  if (entry.kind === "running") {
5230
5532
  renderProgressRow(el, entry.progress);
5231
5533
  const traceContainer = el.querySelector(".dap-trace");
5232
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
5534
+ if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
5233
5535
  renderTokenStats(el, entry);
5234
5536
  return;
5235
5537
  }
@@ -5273,7 +5575,7 @@ function apply(ctx) {
5273
5575
 
5274
5576
  if (entry.kind === "awaiting") {
5275
5577
  const traceContainer = el.querySelector(".dap-trace");
5276
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
5578
+ if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
5277
5579
  removeAwaitingHeadDuration(el);
5278
5580
  renderTokenStats(el, entry);
5279
5581
  const confirm = el.querySelector(".dap-confirm");
@@ -5338,7 +5640,7 @@ function apply(ctx) {
5338
5640
  // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
5339
5641
  // 就地重派生(R-01-009)。
5340
5642
  const listSnap = getSnapshot(sessions, "list");
5341
- captureSessionLog(id, { subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}), cwd: listSnap?.byId?.[id]?.cwd ?? "" });
5643
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
5342
5644
  queueSync();
5343
5645
  });
5344
5646
  } catch {
@@ -5429,15 +5731,18 @@ function apply(ctx) {
5429
5731
  }
5430
5732
 
5431
5733
  /** 数量标识内容写入(R-01-014/AC-06):加载态显示活动指示——已是指示则不重写,
5432
- * 避免每轮 replaceChildren 重启动画抖动;计数态恢复文本写入,textContent 赋值自动摘除指示。 */
5734
+ * 避免每轮 replaceChildren 重启动画抖动;计数态恢复文本写入,textContent 赋值自动摘除指示。
5735
+ * 计数态同时写入悬停 tips(R-01-001/AC-08):说明分子/分母口径;加载态无计数可解释,摘除。 */
5433
5736
  function setCountBadgeContent(el, badge) {
5434
5737
  if (badge.mode === "loading") {
5435
5738
  const spinner = el.firstElementChild;
5436
5739
  if (!(el.childNodes.length === 1 && spinner !== null && spinner.classList.contains("dap-spinner")))
5437
5740
  el.replaceChildren(makeEl("span", "dap-spinner"));
5438
- } else if (el.textContent !== badge.text) {
5741
+ if (el.getAttribute("title") !== null) el.removeAttribute("title");
5742
+ } else {
5439
5743
  // 值未变不写文本节点:aria-live 下相同赋值也会触发替换与重复播报。
5440
- el.textContent = badge.text;
5744
+ if (el.textContent !== badge.text) el.textContent = badge.text;
5745
+ if (el.getAttribute("title") !== badge.tip) el.setAttribute("title", badge.tip);
5441
5746
  }
5442
5747
  }
5443
5748
 
@@ -5966,7 +6271,7 @@ function apply(ctx) {
5966
6271
  }
5967
6272
  }
5968
6273
  if (entry.kind === "awaiting" && detail) {
5969
- entry.elapsedMs = memoTurnDuration(detail, detailSnapshot);
6274
+ entry.elapsedMs = memoTurnDuration(detail);
5970
6275
  }
5971
6276
  if (detail?.model) {
5972
6277
  entry.model = detail.model.model;
@@ -6038,7 +6343,7 @@ function apply(ctx) {
6038
6343
  // 非运行(暂停等待):冻结最后已知统计与最近回合耗时,progress 置空隐藏
6039
6344
  // 进度条(R-01-009/AC-15);刷新/无留存时回退当前列表投影。
6040
6345
  Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
6041
- entry.elapsedMs = detail ? memoTurnDuration(detail, detailSnapshot) : null;
6346
+ entry.elapsedMs = detail ? memoTurnDuration(detail) : null;
6042
6347
  entry.progress = null;
6043
6348
  }
6044
6349
  }
@@ -6061,13 +6366,13 @@ function apply(ctx) {
6061
6366
  recentTotal = recentCandidates.length;
6062
6367
  const recent = recentCandidates.slice(0, recentVisibleCount);
6063
6368
  recentHasMore = recent.length < recentTotal;
6064
- // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
6065
- // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
6369
+ // 最近卡统计复用运行卡的列表投影口径;耗时从已读 history 取最近完整回合的运行
6370
+ // 过程时长(busy 口径,与标题行总耗时一致,R-01-013/AC-12),缺边界时仅为当前
6371
+ // 可见历史卡安排一次既有 history 补读。
6066
6372
  const recentDurationFallbackIds = new Set();
6067
6373
  for (const entry of recent) {
6068
6374
  const detail = sessionDetailsById.get(entry.id);
6069
- const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
6070
- const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
6375
+ const elapsedMs = lastTurnDuration({ history: detail?.history ?? null });
6071
6376
  // 累计运行时长(R-01-020/AC-01):最近历史卡同为标题行右侧显示;历史卡无开放回合。
6072
6377
  applyTotalBusy(entry, now);
6073
6378
  const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
@@ -6145,7 +6450,7 @@ function apply(ctx) {
6145
6450
  // 进入渲染,以便与当前可见等待卡末行重新对相(R-01-002/AC-07)。
6146
6451
  // 历史卡的相对活动时间随分钟级时钟变化,纳入签名后只在文案实际变化时重绘。
6147
6452
  const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
6148
- const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
6453
+ const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature, densityLevel]);
6149
6454
  if (sig === lastSig) return;
6150
6455
  const colorByWorkspace = resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey));
6151
6456
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
@@ -6225,7 +6530,7 @@ function apply(ctx) {
6225
6530
  ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
6226
6531
  if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
6227
6532
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
6228
- const headerEl = pane.querySelector(".dap-header");
6533
+ const headerEl = pane.querySelector(".dap-titlebar");
6229
6534
  const recentHeadEl = recentSection?.querySelector(".dap-recent-head") ?? null;
6230
6535
  for (const [head, hasItems] of [[headerEl, active.length > 0], [recentHeadEl, recent.length > 0]]) {
6231
6536
  if (head === null) continue;
@@ -6237,11 +6542,12 @@ function apply(ctx) {
6237
6542
  }
6238
6543
  }
6239
6544
 
6240
- // 计数与折叠:n/m 只统计主会话——分子为等待行动数、分母为其加运行中主会话之和
6545
+ // 计数与折叠:n/m 只统计主会话——分子为运行中数、分母为其加等待行动主会话之和
6241
6546
  // (R-01-001/AC-04、AC-05);空态同样显示 0/0(AC-06)。列表在途时不冒充计数,
6242
- // 三处数量标识显示加载指示(R-01-014/AC-06)。脉冲由 data-awaiting 承载:
6243
- // 任一等待行动(阻塞等待、完成提醒或错误提醒)即脉冲(R-01-002/AC-06,C-037);
6244
- // 底色经 data-tone 跟随等待构成——错误 > 阻塞 > 完成 优先级取红/金/绿(C-040、C-043)。
6547
+ // 三处数量标识显示加载指示(R-01-014/AC-06)。悬停 tips 说明分子/分母口径(AC-08)。
6548
+ // 脉冲由 data-awaiting 承载:任一等待行动(阻塞等待、完成提醒或错误提醒)即脉冲
6549
+ // (R-01-002/AC-06,C-037);底色经 data-tone 跟随等待构成——错误 > 阻塞 > 完成
6550
+ // 优先级取红/金/绿(C-040、C-043)。
6245
6551
  const count = pane.querySelector(".dap-count");
6246
6552
  const railCount = pane.querySelector(".dap-rail-count");
6247
6553
  const { waiting, blocked, total } = awaitBadgeStats(active);
@@ -6292,6 +6598,21 @@ function apply(ctx) {
6292
6598
  prevRenderedActiveIds = new Set(active.map((entry) => String(entry.id)));
6293
6599
  prevRenderedRecentIds = new Set(recent.map((entry) => String(entry.id)));
6294
6600
  }
6601
+ // 锚定补偿(R-01-021/AC-01):档位切换的渲染落地后量测当前选中卡的新位置并
6602
+ // 补偿 scrollTop,使当前卡顶部回到切换前相对视口的位置;补偿量超滚动边界时
6603
+ // 由浏览器钳制(以当前卡不滚出可视范围为准)。
6604
+ if (pendingDensityAnchor !== null) {
6605
+ const scrollEl = pane.querySelector(".dap-scroll");
6606
+ // 补偿按当前 DOM 的 data-current 卡实时定位:跨区迁移等渲染可能重建卡片
6607
+ // 元素,不持有旧引用(量测恒为 attached 节点)。
6608
+ const currentCard = scrollEl?.querySelector(".dap-card[data-current]") ?? null;
6609
+ if (scrollEl !== null && currentCard !== null) {
6610
+ const viewportTop = scrollEl.getBoundingClientRect().top;
6611
+ const shiftedTop = currentCard.getBoundingClientRect().top - viewportTop;
6612
+ scrollEl.scrollTop += shiftedTop - pendingDensityAnchor;
6613
+ }
6614
+ pendingDensityAnchor = null;
6615
+ }
6295
6616
  }
6296
6617
 
6297
6618
  // ---- 打开会话(让 sessions.open 自己校验列表,失败时 refresh + 重试) ----
@@ -6488,12 +6809,19 @@ function apply(ctx) {
6488
6809
  window.removeEventListener("pageshow", onBusyPageShow);
6489
6810
  document.removeEventListener("visibilitychange", onVisibilityResume);
6490
6811
  window.removeEventListener("pageshow", onPageShow);
6812
+ document.removeEventListener("visibilitychange", onSyncVisibilityResume);
6813
+ window.removeEventListener("pageshow", onSyncPageShow);
6491
6814
  completeAcksById.clear();
6492
6815
  busyById.clear();
6493
6816
  busyRequestedIds.clear();
6494
6817
  busyRetryAtById.clear();
6495
6818
  if (clockTimer !== null) clearInterval(clockTimer);
6496
6819
  if (recentTimeTimer !== null) clearInterval(recentTimeTimer);
6820
+ if (syncThrottleTimer !== null) clearTimeout(syncThrottleTimer);
6821
+ // 流式派生合并窗口:逐 detail 清理在途 timer(T-127)。
6822
+ for (const detail of sessionDetailsById.values()) {
6823
+ if (detail.logDeriveTimer) clearTimeout(detail.logDeriveTimer);
6824
+ }
6497
6825
  if (e2eListReleaseTimer !== null) clearTimeout(e2eListReleaseTimer);
6498
6826
  for (const [timer, resolve] of e2eModelDelayWaiters) {
6499
6827
  clearTimeout(timer);