dsh-activity-pane 0.9.0 → 0.10.1

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
 
@@ -2237,6 +2236,32 @@ function reconcileTurnStats(current, records, { closeOpenTurn = false, forceFres
2237
2236
  return { ...state, watermarkSeq, watermarkTime };
2238
2237
  }
2239
2238
 
2239
+ /**
2240
+ * 会话级登记串行化(R-01-020/AC-02):宿主存储域的 `KvTable.put` 先异步落盘、落盘完成
2241
+ * 后才更新内存快照,因此同一会话的两个边界事件 handler 并发执行时,后到事件会在先到
2242
+ * 事件落盘前读到旧状态,其效果被当作「无效果」永久丢弃(实测:`ask_user_question` 的
2243
+ * `tool/call` 与 11ms 后的 `tool/result` 交错,等待区间悬挂到 `turn/end` 强制结算,
2244
+ * 总耗时系统性缺记整段运行过程)。返回 `(id, run) => tail`:同一会话前一个 run 完成
2245
+ * (含其 put 的内存生效)后才执行下一个 run,不同会话互不阻塞。run 内部须自行捕获
2246
+ * 错误、不得让拒绝外泄(调用方可安全丢弃返回的 tail);tail 供调用方等待与测试断言。
2247
+ * 链空闲即清理,不随会话数增长。
2248
+ */
2249
+ function createSessionEventSerializer() {
2250
+ const tails = new Map();
2251
+ return (id, run) => {
2252
+ const prev = tails.get(id) ?? Promise.resolve();
2253
+ const tail = prev.then(run, run);
2254
+ tails.set(id, tail);
2255
+ tail.then(
2256
+ () => {
2257
+ if (tails.get(id) === tail) tails.delete(id);
2258
+ },
2259
+ () => {},
2260
+ );
2261
+ return tail;
2262
+ };
2263
+ }
2264
+
2240
2265
  /**
2241
2266
  * 构建历史区条目:当前非活动的**主会话**,按最后活动时间从新到旧返回完整候选集合。
2242
2267
  * 子代理是临时工作单元,不入历史区;归档、空会话、完成/错误提醒与委托周期中的会话
@@ -2614,6 +2639,10 @@ const STYLE_ID = "dsh-activity-pane-style";
2614
2639
  const INSTANCE_KEY = "__dshActivityPaneCleanup";
2615
2640
  /** 拖拽调宽的 localStorage 持久化键(R-01-015/AC-04)。 */
2616
2641
  const WIDTH_STORAGE_KEY = "dsh-activity-pane:width";
2642
+ /** 卡片紧凑显示的 localStorage 持久化键(R-01-021/AC-06)。 */
2643
+ const DENSITY_STORAGE_KEY = "dsh-activity-pane:density";
2644
+ /** 三档显示的可访问名称用中文标签,键为档位值(R-01-021/AC-01)。 */
2645
+ const DENSITY_LABELS = { full: "完整", medium: "中间", compact: "紧凑" };
2617
2646
  const COLLAPSED_WIDTH = 34;
2618
2647
  /** 宿主侧完成确认 API 前缀(C-030):acks 快照 / SSE 推送 / ack 写回,同源受信。 */
2619
2648
  const PANE_API_BASE = "/dsh-activity-pane/api";
@@ -2624,6 +2653,10 @@ const INDENT_PX = 16;
2624
2653
  const MOBILE_BREAKPOINT = "767px";
2625
2654
  /** 运行卡时钟:只要存在运行中会话,就以该周期刷新时长显示。 */
2626
2655
  const CLOCK_MS = 1000;
2656
+ /** 渲染与流式派生的最小合并间隔:事件密集(流式输出)时把渲染/派生频率硬顶在
2657
+ * 10Hz——事件率随宿主流式 chunk 数增长,显示粒度(秒级时长、块级时间线)无感,
2658
+ * 而渲染与 O(日志窗口) 派生不再随刷新率(移动端 120Hz)与事件率线性放大(T-127)。 */
2659
+ const SYNC_MIN_INTERVAL_MS = 100;
2627
2660
  /** 历史卡相对时间刷新周期;无需每秒重绘整列。 */
2628
2661
  const RECENT_TIME_REFRESH_MS = 60_000;
2629
2662
  /** 冷数据读取并发池上限:慢网下避免几十张卡片的 models/history 一次性挤占通道。 */
@@ -2653,25 +2686,27 @@ const CSS = `
2653
2686
  [data-dsh-activity-pane] .dap-header {
2654
2687
  display: flex;
2655
2688
  align-items: center;
2656
- gap: 8px;
2657
- padding: 10px 12px;
2658
2689
  font-size: 12px;
2659
2690
  font-weight: 700;
2660
2691
  letter-spacing: 0.02em;
2661
2692
  }
2662
- /* 标题行整体即收起控件,两端断点一致(R-01-011/AC-03、R-01-008/AC-02):指针与悬停/聚焦反馈。 */
2663
- [data-dsh-activity-pane] .dap-header { cursor: pointer; }
2664
- [data-dsh-activity-pane] .dap-header:hover,
2665
- [data-dsh-activity-pane] .dap-header:focus-visible {
2666
- background: color-mix(in srgb, currentColor 8%, transparent);
2693
+ /* 标题行拆为两部分:左侧标题区(flex:1 占满剩余宽度)整体即收起控件,悬停/聚焦
2694
+ 高亮只覆盖标题区(R-01-011/AC-03、AC-07、R-01-008/AC-02);右侧工具区独立,
2695
+ 不参与标题区的悬停高亮与折叠激活。 */
2696
+ [data-dsh-activity-pane] .dap-titlebar {
2697
+ flex: 1;
2698
+ min-width: 0;
2699
+ display: flex;
2700
+ align-items: center;
2701
+ gap: 8px;
2702
+ padding: 10px 8px 10px 12px;
2703
+ cursor: pointer;
2667
2704
  }
2668
- [data-dsh-activity-pane] .dap-header:focus-visible { outline: none; }
2669
- /* 方向符号:标题行的一部分而非独立按钮,两端断点一致呈现(R-01-011/AC-03、R-01-008/AC-02)。 */
2670
- [data-dsh-activity-pane] .dap-collapse-hint {
2671
- margin-left: auto;
2672
- color: color-mix(in srgb, currentColor 45%, transparent);
2673
- font-size: 13px;
2705
+ [data-dsh-activity-pane] .dap-titlebar:hover,
2706
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible {
2707
+ background: color-mix(in srgb, currentColor 8%, transparent);
2674
2708
  }
2709
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible { outline: none; }
2675
2710
  [data-dsh-activity-pane] .dap-count {
2676
2711
  flex: none;
2677
2712
  font-size: 10px;
@@ -2742,13 +2777,13 @@ const CSS = `
2742
2777
  scrollbar-color: var(--dsh-scrollbar-thumb, color-mix(in srgb, currentColor 25%, transparent)) transparent;
2743
2778
  }
2744
2779
  }
2745
- /* 「回到顶部」悬浮图标按钮(R-01-018):窗格内右下角圆形按钮,纯图标无文字、不透明底色;
2746
- 默认 hidden,scrollTop 超阈值时由滚动监听揭隐。基类 display:flex 会压过 UA 的
2780
+ /* 「回到顶部」(R-01-018)悬浮图标按钮:右缘对齐、28px 圆形、不透明底色。
2781
+ 默认 hidden,scrollTop 超阈值时由滚动监听揭隐;基类 display:flex 会压过 UA 的
2747
2782
  [hidden] 规则,故显式补 [hidden] 隐藏。 */
2748
2783
  [data-dsh-activity-pane] .dap-top {
2749
2784
  position: absolute;
2750
- bottom: 12px;
2751
2785
  right: 12px;
2786
+ bottom: 12px;
2752
2787
  z-index: 6;
2753
2788
  display: flex;
2754
2789
  align-items: center;
@@ -2767,6 +2802,109 @@ const CSS = `
2767
2802
  [data-dsh-activity-pane] .dap-top:focus-visible {
2768
2803
  background: #262932;
2769
2804
  }
2805
+ /* 标题行右侧工具按钮区(R-01-021/AC-05):固定于行尾、常显档位切换按钮,未来新
2806
+ 工具按钮统一加入此区;宽度不随悬停态变化,收起方向图标的显隐不挤动本区按钮;
2807
+ 左缘 padding 12px 与标题区保持充分间隔。收起方向图标(R-01-011/AC-07)是标题区
2808
+ 的行尾提示:常态宽度为 0 不占位,鼠标悬停或键盘聚焦标题区时在标题区最右端即时
2809
+ 显现(无过渡动画),离开即隐藏。 */
2810
+ [data-dsh-activity-pane] .dap-tools {
2811
+ display: flex;
2812
+ align-items: center;
2813
+ gap: 4px;
2814
+ padding: 10px 12px;
2815
+ }
2816
+ [data-dsh-activity-pane] .dap-density {
2817
+ display: flex;
2818
+ align-items: center;
2819
+ justify-content: center;
2820
+ width: 22px;
2821
+ height: 22px;
2822
+ padding: 0;
2823
+ border: 1px solid rgba(255, 255, 255, 0.14);
2824
+ border-radius: 999px;
2825
+ background: #1d1f25;
2826
+ color: inherit;
2827
+ cursor: pointer;
2828
+ }
2829
+ [data-dsh-activity-pane] .dap-density:hover,
2830
+ [data-dsh-activity-pane] .dap-density:focus-visible {
2831
+ background: #262932;
2832
+ }
2833
+ /* 仓库入口无描边(T-139):视觉强度弱于带描边的档位切换按钮,仅以不透明底色圆形呈现。 */
2834
+ [data-dsh-activity-pane] .dap-repo {
2835
+ display: flex;
2836
+ align-items: center;
2837
+ justify-content: center;
2838
+ width: 22px;
2839
+ height: 22px;
2840
+ border-radius: 999px;
2841
+ background: #1d1f25;
2842
+ color: inherit;
2843
+ cursor: pointer;
2844
+ text-decoration: none;
2845
+ }
2846
+ [data-dsh-activity-pane] .dap-repo:hover,
2847
+ [data-dsh-activity-pane] .dap-repo:focus-visible {
2848
+ background: #262932;
2849
+ }
2850
+ [data-dsh-activity-pane] .dap-collapse-hint {
2851
+ margin-left: auto;
2852
+ display: flex;
2853
+ align-items: center;
2854
+ justify-content: center;
2855
+ width: 0;
2856
+ height: 22px;
2857
+ opacity: 0;
2858
+ overflow: hidden;
2859
+ }
2860
+ [data-dsh-activity-pane] .dap-titlebar:hover .dap-collapse-hint,
2861
+ [data-dsh-activity-pane] .dap-titlebar:focus-visible .dap-collapse-hint {
2862
+ width: 22px;
2863
+ opacity: 1;
2864
+ }
2865
+ /* 卡片显示档位(R-01-021):中间档保留标题行、工作区徽标行、等待末行与最近卡
2866
+ 消息预览行、经渲染层 lastOnly 单行渲染时间线(仅最新一行,AC-08);完成提醒卡
2867
+ 末行在中间档收合为单行(见下方 data-wait="done" 作用域规则);紧凑档在
2868
+ 中间档基础上再隐藏工作区徽标行、时间线末行、等待末行与消息预览行,仅保留
2869
+ 标题行(AC-02)——激活跳转逻辑不感知档位,渲染签名含显示档位分量(档位切换
2870
+ 经 queueSync 触发一轮重渲染)(R-01-021/AC-04)。 */
2871
+ [data-dsh-activity-pane][data-density="medium"] .dap-card :is(
2872
+ .dap-progress,
2873
+ .dap-token-stats
2874
+ ) {
2875
+ display: none;
2876
+ }
2877
+ [data-dsh-activity-pane][data-density="compact"] .dap-card :is(
2878
+ .dap-card-head,
2879
+ .dap-trace,
2880
+ .dap-subtrace,
2881
+ .dap-progress,
2882
+ .dap-token-stats,
2883
+ .dap-foot,
2884
+ .dap-history-line,
2885
+ .dap-note
2886
+ ) {
2887
+ display: none;
2888
+ }
2889
+ /* 中间档完成提醒卡末行收合为单行(R-01-021/AC-08,东家反馈):末行两行内容
2890
+ (「已完成」胶囊行 +「继续对话,或移入历史」与「移入历史」按钮行)冗余,收合为
2891
+ 单行——「已完成」胶囊居左、按钮 margin-left:auto 居右、正文不再显示;两段包裹层
2892
+ 以 display: contents 释放为同行 flex 项。选择器以卡片根 data-wait="done" 作用域,
2893
+ 阻塞/错误提醒卡与完整呈现档的两行结构不受影响,紧凑档仍整体隐藏末行。 */
2894
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-foot {
2895
+ flex-direction: row;
2896
+ align-items: center;
2897
+ gap: 6px;
2898
+ }
2899
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] :is(.dap-await-head, .dap-note-row) {
2900
+ display: contents;
2901
+ }
2902
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-note {
2903
+ display: none;
2904
+ }
2905
+ [data-dsh-activity-pane][data-density="medium"] .dap-card[data-kind="awaiting"][data-wait="done"] .dap-confirm {
2906
+ margin-left: auto;
2907
+ }
2770
2908
  [data-dsh-activity-pane] .dap-list {
2771
2909
  display: flex;
2772
2910
  flex-direction: column;
@@ -3328,17 +3466,30 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3328
3466
  [data-dsh-activity-pane] .dap-fill {
3329
3467
  position: absolute; inset: 0 auto 0 0; width: 0%;
3330
3468
  border-radius: 6px;
3331
- background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);
3332
- background-size: 200% 100%;
3469
+ overflow: hidden;
3333
3470
  box-shadow: 0 0 7px rgba(88, 201, 143, 0.5);
3334
3471
  transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1);
3335
3472
  /* 进度条仅存于运行卡骨架:会话运行全程持续向右滚动条纹,作为活动标志
3336
3473
  (对齐 answer-pet 的 ap-stripes,R-01-009/AC-08)。 */
3474
+ /* 条带载体 ::after 与滚动动画分离(T-128):background-position 不可合成,
3475
+ 每帧重绘;改由伪元素 transform 平移承载滚动,fill 只保留 width 过渡与
3476
+ 裁切,滚动帧全程合成器驱动。 */
3477
+ }
3478
+ [data-dsh-activity-pane] .dap-fill::after {
3479
+ content: "";
3480
+ position: absolute;
3481
+ inset: 0 auto 0 0;
3482
+ /* 覆盖 fill 宽度 + 一次位移量:平移全程右缘不落后于 fill 右缘,无缝循环。 */
3483
+ width: calc(100% + 40px);
3484
+ /* 周期 20px(色带 10px)与原 background-position 实现一致——px 色标不受
3485
+ background-size 拉伸(T-128 双轴实测);translateX(-40px) 恰为 2 个周期,
3486
+ 无缝且速度 40px/0.8s 与原实现一致。 */
3487
+ background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);
3337
3488
  animation: dap-stripes 0.8s linear infinite;
3338
3489
  }
3339
3490
  @keyframes dap-stripes {
3340
- from { background-position: 0 0; }
3341
- to { background-position: 40px 0; }
3491
+ from { transform: translateX(-40px); }
3492
+ to { transform: translateX(0); }
3342
3493
  }
3343
3494
  @media (prefers-reduced-motion: reduce) {
3344
3495
  /* answer-pet 保留状态脉冲/进度条纹;仅关闭宽度过渡,避免状态反馈消失。 */
@@ -3448,6 +3599,11 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3448
3599
  touch-action: none;
3449
3600
  }
3450
3601
  [data-dsh-activity-pane][data-open="true"] { transform: translateX(0); }
3602
+ /* 屏外休眠(T-127):抽屉关闭(含初始未开,data-open 缺省视同关闭)时子树整体
3603
+ 跳过渲染——无限动画与样式失效不再产生渲染开销;布局状态保留,scrollTop 不归零
3604
+ (区别于 display:none,T-027);打开瞬间恢复渲染,滑入过渡不变。开关与遮罩挂
3605
+ body,不落本规则,浮动开关徽标脉冲(R-01-002/AC-06、AC-07)照常。 */
3606
+ [data-dsh-activity-pane]:not([data-open="true"]) { content-visibility: hidden; }
3451
3607
  .dap-backdrop[data-drawer-open] { display: block; }
3452
3608
  .dap-toggle { display: flex; }
3453
3609
  .dap-toggle[data-drawer-open] { display: none; }
@@ -3541,13 +3697,23 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
3541
3697
  body:not([data-ds-dark-theme]) .dap-toggle {
3542
3698
  background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
3543
3699
  }
3544
- /* 「回到顶部」图标按钮浅色覆盖:不透明层-2 底色与外壳描边别名(R-01-018/AC-05)。 */
3545
- body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top {
3700
+ /* 「回到顶部」与标题行工具区档位/仓库入口按钮的浅色覆盖:不透明层-2 底色与外壳描边别名
3701
+ (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
3702
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3703
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density,
3704
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo {
3546
3705
  background: var(--dsw-alias-bg-layer-2, #ffffff);
3706
+ }
3707
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
3708
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density {
3547
3709
  border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
3548
3710
  }
3549
3711
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:hover,
3550
- body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:focus-visible {
3712
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top:focus-visible,
3713
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density:hover,
3714
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-density:focus-visible,
3715
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo:hover,
3716
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-repo:focus-visible {
3551
3717
  background: var(--dsw-alias-bg-layer-3, #eceef1);
3552
3718
  }
3553
3719
  `;
@@ -3623,6 +3789,22 @@ function writeStoredPaneWidth(width) {
3623
3789
  } catch {}
3624
3790
  }
3625
3791
 
3792
+ /** 读取持久化卡片显示档位:缺失/非法值经 normalizeDensity 归一为默认中间档;
3793
+ * localStorage 不可用(隐私模式)静默回退中间档(R-01-021/AC-06)。 */
3794
+ function readStoredDensity() {
3795
+ try {
3796
+ return normalizeDensity(window.localStorage.getItem(DENSITY_STORAGE_KEY));
3797
+ } catch {
3798
+ return "medium";
3799
+ }
3800
+ }
3801
+ /** 切换时持久化卡片显示档位;localStorage 不可用时静默跳过(R-01-021/AC-06)。 */
3802
+ function writeStoredDensity(value) {
3803
+ try {
3804
+ window.localStorage.setItem(DENSITY_STORAGE_KEY, value);
3805
+ } catch {}
3806
+ }
3807
+
3626
3808
  function apply(ctx) {
3627
3809
  const previousCleanup = document[INSTANCE_KEY] ?? globalThis[INSTANCE_KEY];
3628
3810
  if (typeof previousCleanup === "function") previousCleanup();
@@ -3640,6 +3822,10 @@ function apply(ctx) {
3640
3822
  let clockTimer = null;
3641
3823
  let recentTimeTimer = null;
3642
3824
  let syncScheduled = false;
3825
+ /** 渲染节流状态(T-127):lastSyncAt 取 0 保证首轮立即渲染;syncThrottleTimer 为
3826
+ * 节流窗口尾的在途 timer,交付或卸载时清空。 */
3827
+ let lastSyncAt = 0;
3828
+ let syncThrottleTimer = null;
3643
3829
  let lastSig = "";
3644
3830
  /** 等待条目 id/类别队列签名:变化时统一重启数量胶囊与等待卡末行动画对相(R-01-002/AC-07、AC-08)。 */
3645
3831
  let pulseSignature = "";
@@ -3674,6 +3860,12 @@ function apply(ctx) {
3674
3860
  let collapsed = false;
3675
3861
  /** 当前桌面列宽:启动时从 localStorage 恢复,拖拽实时更新,重挂载后保留(R-01-015)。 */
3676
3862
  let paneWidth = readStoredPaneWidth();
3863
+ /** 卡片显示档位(full/medium/compact):启动时从 localStorage 恢复,切换实时更新,
3864
+ * 重挂载后保留(R-01-021/AC-06)。 */
3865
+ let densityLevel = readStoredDensity();
3866
+ /** 待执行的锚定补偿:档位切换时登记当前选中卡片顶部的视口相对位置,本轮渲染
3867
+ * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。 */
3868
+ let pendingDensityAnchor = null;
3677
3869
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
3678
3870
  let lastActivatedId = null;
3679
3871
  /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
@@ -3841,13 +4033,23 @@ function apply(ctx) {
3841
4033
  if (changed) notifyLayoutChange();
3842
4034
  }
3843
4035
 
4036
+ function deliverSync() {
4037
+ syncScheduled = false;
4038
+ syncThrottleTimer = null;
4039
+ if (disposed) return;
4040
+ lastSyncAt = Date.now();
4041
+ render();
4042
+ }
4043
+
4044
+ /** 渲染入口合帧 + 节流(T-127):距上次渲染落点不足 SYNC_MIN_INTERVAL_MS 时,
4045
+ * 本轮请求合并到窗口尾由 timer 交付(在途请求只此一个);达到间隔走原 rAF
4046
+ * 合帧立即渲染。空闲期无事件即无排队,不引入常驻唤醒。 */
3844
4047
  function queueSync() {
3845
4048
  if (disposed || syncScheduled) return;
3846
4049
  syncScheduled = true;
3847
- schedule(() => {
3848
- syncScheduled = false;
3849
- if (!disposed) render();
3850
- });
4050
+ const wait = SYNC_MIN_INTERVAL_MS - (Date.now() - lastSyncAt);
4051
+ if (wait > 0) syncThrottleTimer = setTimeout(deliverSync, wait);
4052
+ else schedule(deliverSync);
3851
4053
  }
3852
4054
 
3853
4055
  // ---- 完成确认通道(R-01-002/AC-10~AC-12、R-01-010/AC-06,C-030) ----
@@ -4037,6 +4239,36 @@ function apply(ctx) {
4037
4239
  window.addEventListener("pageshow", onBusyPageShow);
4038
4240
  connectBusyStream();
4039
4241
 
4242
+ // ---- 回到前台渲染管线自愈(R-01-001/AC-03 回归修复) ----
4243
+ // iOS 后台挂起会丢弃在途 setTimeout:queueSync 的节流窗口尾与流式派生窗口若在
4244
+ // 挂起前排队,恢复后永不交付——syncScheduled/logDeriveTimer 永久滞留,此后一切
4245
+ // 更新(store 订阅推送、SSE 快照、时钟 tick)都被 queueSync 早退吞掉,窗格停留
4246
+ // 在挂起前状态(会话已完成仍显示运行中)。回前台时无条件清掉两类在途 timer 并
4247
+ // 立即交付一轮渲染(rAF 路径挂起安全),与 acks/busy 通道回前台重建同模式。
4248
+ function resumeRenderPipeline() {
4249
+ if (disposed) return;
4250
+ if (syncThrottleTimer !== null) {
4251
+ clearTimeout(syncThrottleTimer);
4252
+ syncThrottleTimer = null;
4253
+ }
4254
+ syncScheduled = false;
4255
+ for (const detail of sessionDetailsById.values()) {
4256
+ if (detail.logDeriveTimer && detail.logDeriveFlush) {
4257
+ clearTimeout(detail.logDeriveTimer);
4258
+ detail.logDeriveFlush();
4259
+ }
4260
+ }
4261
+ schedule(deliverSync);
4262
+ }
4263
+ const onSyncVisibilityResume = () => {
4264
+ if (document.visibilityState === "visible") resumeRenderPipeline();
4265
+ };
4266
+ const onSyncPageShow = (event) => {
4267
+ if (event?.persisted === true) resumeRenderPipeline();
4268
+ };
4269
+ document.addEventListener("visibilitychange", onSyncVisibilityResume);
4270
+ window.addEventListener("pageshow", onSyncPageShow);
4271
+
4040
4272
  function remoteValue(response) {
4041
4273
  if (response?.ok === true) return response.value;
4042
4274
  throw response?.error ?? new Error("remote request failed");
@@ -4175,7 +4407,7 @@ function apply(ctx) {
4175
4407
  });
4176
4408
  }
4177
4409
  }
4178
- captureSessionLog(id, { subagent, cwd: byId[id]?.cwd ?? "" });
4410
+ captureSessionLog(id, { cwd: byId[id]?.cwd ?? "" });
4179
4411
  // 长会话深读兜底(R-01-013/AC-03):最近卡预览/子代理溯源不在尾页日志窗口内
4180
4412
  // 且宿主标记 hasMore 时,按 beforeSeq 向前回溯翻页(默认无页数上限)。
4181
4413
  const windowEntries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
@@ -4251,7 +4483,7 @@ function apply(ctx) {
4251
4483
  /** 绑定会话并水合事件源(dsh 0.1.5 起会话内容经 eventSource 流式下发:打开即收
4252
4484
  * 完整日志窗口 + 实时尾,原生 Conversation 同源)。冷会话补一次 open(),日志窗口
4253
4485
  * 快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。 */
4254
- function captureSessionLog(id, { subagent, cwd }) {
4486
+ function captureSessionLog(id, { cwd } = {}) {
4255
4487
  const detail = sessionDetailsById.get(id) ?? {};
4256
4488
  sessionDetailsById.set(id, detail);
4257
4489
  let session = null;
@@ -4272,10 +4504,7 @@ function apply(ctx) {
4272
4504
  session.eventSource.subscribe(() => {
4273
4505
  if (disposed) return;
4274
4506
  const listSnap = getSnapshot(sessions, "list");
4275
- captureSessionLog(id, {
4276
- subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}),
4277
- cwd: listSnap?.byId?.[id]?.cwd ?? "",
4278
- });
4507
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
4279
4508
  queueSync();
4280
4509
  }),
4281
4510
  );
@@ -4301,8 +4530,30 @@ function apply(ctx) {
4301
4530
  const log = session.eventSource?.getSnapshot?.() ?? null;
4302
4531
  if (log === detail.log) return;
4303
4532
  detail.log = log;
4304
- const entries = Array.isArray(log?.entries) ? log.entries : [];
4305
- applyLogEvents(id, detail, entries, { subagent, cwd });
4533
+ // 流式派生合并(T-127):事件到达只更新引用并标脏,applyLogEvents O(日志窗口)
4534
+ // 全量折叠/预览/模型提取合并进 SYNC_MIN_INTERVAL_MS 窗口执行——事件率与派生成本
4535
+ // 解耦,消化时读到的即最新窗口。subagent/cwd 在回调内经 list 快照现取(timer 在途
4536
+ // 期间行属性可能突变,建窗入参不代表消化时刻;与 logSourceSubs 回调同模式),
4537
+ // cwd 在快照不可得时回退建窗入参(subagent 现取即权威,无建窗回退)。
4538
+ // 深翻路径为一次性同步调用,不经本窗口。交付体同时挂在 detail.logDeriveFlush
4539
+ // 上:回前台自愈据此补交付被挂起丢弃的窗口(resumeRenderPipeline)。
4540
+ if (!detail.logDeriveTimer && typeof setTimeout === "function") {
4541
+ const flushDerive = () => {
4542
+ detail.logDeriveTimer = null;
4543
+ detail.logDeriveFlush = null;
4544
+ if (disposed) return;
4545
+ const listSnap = getSnapshot(sessions, "list");
4546
+ const entries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
4547
+ applyLogEvents(id, detail, entries, {
4548
+ subagent: isSubagentRow(listSnap?.byId?.[id], listSnap?.byId ?? {}),
4549
+ cwd: listSnap?.byId?.[id]?.cwd ?? cwd,
4550
+ });
4551
+ queueSync();
4552
+ };
4553
+ detail.logDeriveFlush = flushDerive;
4554
+ detail.logDeriveTimer = setTimeout(flushDerive, SYNC_MIN_INTERVAL_MS);
4555
+ }
4556
+ queueSync();
4306
4557
  }
4307
4558
 
4308
4559
  /** 部署级模型目录一次性读取(R-01-012/AC-01):dsh 0.1.5 起 per-session models
@@ -4380,7 +4631,7 @@ function apply(ctx) {
4380
4631
  return true;
4381
4632
  }
4382
4633
  function bindPaneControls(pane) {
4383
- const header = pane.querySelector(".dap-header");
4634
+ const header = pane.querySelector(".dap-titlebar");
4384
4635
  const rail = pane.querySelector(".dap-rail");
4385
4636
  const resize = pane.querySelector(".dap-resize");
4386
4637
  const scroll = pane.querySelector(".dap-scroll");
@@ -4482,6 +4733,37 @@ function apply(ctx) {
4482
4733
  const onTopClick = () => {
4483
4734
  scroll?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
4484
4735
  };
4736
+ // 显示档位循环切换(R-01-021/AC-01):完整→中间→紧凑→完整,形态写窗格根属性
4737
+ // 驱动纯 CSS 呈现,持久化于 localStorage(AC-06),会话状态变化不触碰已选档位(AC-07)。
4738
+ const densityBtn = pane.querySelector(".dap-density");
4739
+ const applyDensity = () => {
4740
+ pane.setAttribute("data-density", densityLevel);
4741
+ if (densityBtn !== null) {
4742
+ const next = nextDensity(densityLevel);
4743
+ densityBtn.setAttribute("aria-label", `切换为${DENSITY_LABELS[next]}显示`);
4744
+ densityBtn.title = `${DENSITY_LABELS[next]}显示`;
4745
+ }
4746
+ };
4747
+ const onDensityClick = () => {
4748
+ // 滚动锚定(R-01-021/AC-01):记录切换前当前选中卡片顶部相对滚动视口的
4749
+ // 位置,档位翻转后把 scrollTop 补偿回该相对位置,使卡片顶部在屏幕上不动。
4750
+ const scrollEl = scroll ?? pane.querySelector(".dap-scroll");
4751
+ const currentCard = scrollEl?.querySelector(".dap-card[data-current]") ?? null;
4752
+ const viewportTop = scrollEl?.getBoundingClientRect().top ?? 0;
4753
+ const anchorTop = currentCard ? currentCard.getBoundingClientRect().top - viewportTop : null;
4754
+ densityLevel = nextDensity(densityLevel);
4755
+ writeStoredDensity(densityLevel);
4756
+ applyDensity();
4757
+ // 档位已纳入渲染签名:触发一轮同步让时间线按新档位以 lastOnly 重建;
4758
+ // 锚定补偿在本轮渲染提交后执行,此时量测才含新行高
4759
+ //(R-01-021/AC-01、AC-08)。
4760
+ pendingDensityAnchor = currentCard && anchorTop !== null ? anchorTop : null;
4761
+ queueSync();
4762
+ };
4763
+ // 档位按钮位于标题行右侧的工具区(标题区折叠控件的兄弟节点),激活天然不会
4764
+ // 冒泡为标题区折叠,无需额外阻断。
4765
+ densityBtn?.addEventListener("click", onDensityClick);
4766
+ applyDensity();
4485
4767
  header?.addEventListener("click", onHeaderActivate);
4486
4768
  header?.addEventListener("keydown", onHeaderKeydown);
4487
4769
  rail?.addEventListener("click", onRailClick);
@@ -4502,6 +4784,7 @@ function apply(ctx) {
4502
4784
  recentMore?.removeEventListener("click", onRecentMoreClick);
4503
4785
  if (scrollHideTimer !== null) clearTimeout(scrollHideTimer);
4504
4786
  topBtn?.removeEventListener("click", onTopClick);
4787
+ densityBtn?.removeEventListener("click", onDensityClick);
4505
4788
  resize?.removeEventListener("pointerdown", onResizeDown);
4506
4789
  };
4507
4790
  }
@@ -4521,10 +4804,16 @@ function apply(ctx) {
4521
4804
  pane.className = PANE_CLASS;
4522
4805
  center.insertBefore(pane, seat);
4523
4806
  pane.innerHTML = `
4524
- <div class="dap-header" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4525
- <span>活动会话</span>
4526
- <span class="dap-count" role="status" aria-live="polite"></span>
4527
- <span class="dap-collapse-hint" aria-hidden="true">«</span>
4807
+ <div class="dap-header">
4808
+ <span class="dap-titlebar" role="button" tabindex="0" aria-expanded="true" aria-label="收起活动会话窗格" title="收起">
4809
+ <span>活动会话</span>
4810
+ <span class="dap-count" role="status" aria-live="polite"></span>
4811
+ <span class="dap-collapse-hint" aria-hidden="true"></span>
4812
+ </span>
4813
+ <span class="dap-tools">
4814
+ <a class="dap-repo" href="https://github.com/ccll/dsh-activity-pane" target="_blank" rel="noreferrer noopener" aria-label="报告问题" title="报告问题"></a>
4815
+ <button class="dap-density" type="button" aria-label="切换为紧凑显示" title="紧凑显示"></button>
4816
+ </span>
4528
4817
  </div>
4529
4818
  <div class="dap-scroll">
4530
4819
  <div class="dap-list" tabindex="-1"><div class="dap-tracks" aria-hidden="true"></div></div>
@@ -4543,6 +4832,13 @@ function apply(ctx) {
4543
4832
  pane.style.setProperty("--dap-width", `${paneWidth}px`);
4544
4833
  // 「回到顶部」按钮为纯图标呈现(R-01-018/AC-05):骨架无文字,图标在创建时注入。
4545
4834
  pane.querySelector(".dap-top").append(createTopIcon());
4835
+ // 紧凑显示切换按钮常显于标题行右侧工具区(R-01-021/AC-05);切换时以 data-density
4836
+ // 驱动纯 CSS 呈现,骨架重建后由 bindPaneControls 的 applyDensity 恢复形态。
4837
+ pane.querySelector(".dap-density").append(createDensityIcon());
4838
+ // 仓库入口常显于标题行右侧工具区(R-01-022/AC-01);图标在创建时注入。
4839
+ pane.querySelector(".dap-repo").append(createRepoIcon());
4840
+ // 收起方向图标为标题行悬停/聚焦的可见性提示(R-01-011/AC-07),图标在创建时注入。
4841
+ pane.querySelector(".dap-collapse-hint").append(createCollapseIcon());
4546
4842
  }
4547
4843
  if (pane !== boundPane) {
4548
4844
  unbindPaneControls?.();
@@ -4715,6 +5011,50 @@ function apply(ctx) {
4715
5011
  });
4716
5012
  }
4717
5013
 
5014
+ /** 紧凑显示切换按钮的「多行收拢为单行」密度图标:上下两个指向中线的箭头夹一条
5015
+ * 标题行横线,与「回到顶部」的单向箭头、标题行的收起方向图标均可区分
5016
+ * (canonical 图标集无现成字形,14 盒 stroke 风格与 createTopIcon 一致,R-01-021/AC-05)。 */
5017
+ function createDensityIcon() {
5018
+ return createInlineIcon({
5019
+ viewBox: "0 0 14 14",
5020
+ width: 14,
5021
+ height: 14,
5022
+ parts: [
5023
+ { 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" } },
5024
+ { attrs: { d: "M3 7h8", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5025
+ { 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" } },
5026
+ ],
5027
+ });
5028
+ }
5029
+
5030
+ /** 标题行的「收起」方向图标(R-01-011/AC-07):左侧竖杠 + 向左箭头指向竖杠,
5031
+ * 表达窗格收缩到左侧;随 .dap-collapse-hint 在标题行悬停/聚焦时显现。 */
5032
+ function createCollapseIcon() {
5033
+ return createInlineIcon({
5034
+ viewBox: "0 0 14 14",
5035
+ width: 14,
5036
+ height: 14,
5037
+ parts: [
5038
+ { attrs: { d: "M3 2.5v9", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5039
+ { attrs: { d: "M11.5 7H5", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5040
+ { attrs: { d: "M8.25 3.75 5 7l3.25 3.25", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" } },
5041
+ ],
5042
+ });
5043
+ }
5044
+
5045
+ /** 标题行工具区的仓库入口图标(R-01-022/AC-01):GitHub Octicon `mark-github` 字形
5046
+ * (MIT 许可,Primer Octicons),与工具区既有图标同用 14px 字形盒。 */
5047
+ function createRepoIcon() {
5048
+ return createInlineIcon({
5049
+ viewBox: "0 0 16 16",
5050
+ width: 14,
5051
+ height: 14,
5052
+ parts: [
5053
+ { 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" } },
5054
+ ],
5055
+ });
5056
+ }
5057
+
4718
5058
  function createUserIcon() {
4719
5059
  return createInlineIcon({
4720
5060
  viewBox: "0 0 16 16",
@@ -5217,7 +5557,7 @@ function apply(ctx) {
5217
5557
  if (entry.kind === "running") {
5218
5558
  renderProgressRow(el, entry.progress);
5219
5559
  const traceContainer = el.querySelector(".dap-trace");
5220
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
5560
+ if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
5221
5561
  renderTokenStats(el, entry);
5222
5562
  return;
5223
5563
  }
@@ -5261,7 +5601,7 @@ function apply(ctx) {
5261
5601
 
5262
5602
  if (entry.kind === "awaiting") {
5263
5603
  const traceContainer = el.querySelector(".dap-trace");
5264
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
5604
+ if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
5265
5605
  removeAwaitingHeadDuration(el);
5266
5606
  renderTokenStats(el, entry);
5267
5607
  const confirm = el.querySelector(".dap-confirm");
@@ -5326,7 +5666,7 @@ function apply(ctx) {
5326
5666
  // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
5327
5667
  // 就地重派生(R-01-009)。
5328
5668
  const listSnap = getSnapshot(sessions, "list");
5329
- captureSessionLog(id, { subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}), cwd: listSnap?.byId?.[id]?.cwd ?? "" });
5669
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
5330
5670
  queueSync();
5331
5671
  });
5332
5672
  } catch {
@@ -5417,15 +5757,18 @@ function apply(ctx) {
5417
5757
  }
5418
5758
 
5419
5759
  /** 数量标识内容写入(R-01-014/AC-06):加载态显示活动指示——已是指示则不重写,
5420
- * 避免每轮 replaceChildren 重启动画抖动;计数态恢复文本写入,textContent 赋值自动摘除指示。 */
5760
+ * 避免每轮 replaceChildren 重启动画抖动;计数态恢复文本写入,textContent 赋值自动摘除指示。
5761
+ * 计数态同时写入悬停 tips(R-01-001/AC-08):说明分子/分母口径;加载态无计数可解释,摘除。 */
5421
5762
  function setCountBadgeContent(el, badge) {
5422
5763
  if (badge.mode === "loading") {
5423
5764
  const spinner = el.firstElementChild;
5424
5765
  if (!(el.childNodes.length === 1 && spinner !== null && spinner.classList.contains("dap-spinner")))
5425
5766
  el.replaceChildren(makeEl("span", "dap-spinner"));
5426
- } else if (el.textContent !== badge.text) {
5767
+ if (el.getAttribute("title") !== null) el.removeAttribute("title");
5768
+ } else {
5427
5769
  // 值未变不写文本节点:aria-live 下相同赋值也会触发替换与重复播报。
5428
- el.textContent = badge.text;
5770
+ if (el.textContent !== badge.text) el.textContent = badge.text;
5771
+ if (el.getAttribute("title") !== badge.tip) el.setAttribute("title", badge.tip);
5429
5772
  }
5430
5773
  }
5431
5774
 
@@ -6133,7 +6476,7 @@ function apply(ctx) {
6133
6476
  // 进入渲染,以便与当前可见等待卡末行重新对相(R-01-002/AC-07)。
6134
6477
  // 历史卡的相对活动时间随分钟级时钟变化,纳入签名后只在文案实际变化时重绘。
6135
6478
  const recentTimeSignature = recent.map((entry) => fmtRecentTime(entry.activityAt));
6136
- const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature]);
6479
+ const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface, recentTimeSignature, densityLevel]);
6137
6480
  if (sig === lastSig) return;
6138
6481
  const colorByWorkspace = resolveWorkspaceColors(visibleEntries.map((entry) => entry.workspaceKey));
6139
6482
  // 跨区迁移(双向,R-01-010/AC-07):DOM 写入前量取旧卡矩形并克隆 ghost。
@@ -6213,7 +6556,7 @@ function apply(ctx) {
6213
6556
  ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
6214
6557
  if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
6215
6558
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
6216
- const headerEl = pane.querySelector(".dap-header");
6559
+ const headerEl = pane.querySelector(".dap-titlebar");
6217
6560
  const recentHeadEl = recentSection?.querySelector(".dap-recent-head") ?? null;
6218
6561
  for (const [head, hasItems] of [[headerEl, active.length > 0], [recentHeadEl, recent.length > 0]]) {
6219
6562
  if (head === null) continue;
@@ -6225,11 +6568,12 @@ function apply(ctx) {
6225
6568
  }
6226
6569
  }
6227
6570
 
6228
- // 计数与折叠:n/m 只统计主会话——分子为等待行动数、分母为其加运行中主会话之和
6571
+ // 计数与折叠:n/m 只统计主会话——分子为运行中数、分母为其加等待行动主会话之和
6229
6572
  // (R-01-001/AC-04、AC-05);空态同样显示 0/0(AC-06)。列表在途时不冒充计数,
6230
- // 三处数量标识显示加载指示(R-01-014/AC-06)。脉冲由 data-awaiting 承载:
6231
- // 任一等待行动(阻塞等待、完成提醒或错误提醒)即脉冲(R-01-002/AC-06,C-037);
6232
- // 底色经 data-tone 跟随等待构成——错误 > 阻塞 > 完成 优先级取红/金/绿(C-040、C-043)。
6573
+ // 三处数量标识显示加载指示(R-01-014/AC-06)。悬停 tips 说明分子/分母口径(AC-08)。
6574
+ // 脉冲由 data-awaiting 承载:任一等待行动(阻塞等待、完成提醒或错误提醒)即脉冲
6575
+ // (R-01-002/AC-06,C-037);底色经 data-tone 跟随等待构成——错误 > 阻塞 > 完成
6576
+ // 优先级取红/金/绿(C-040、C-043)。
6233
6577
  const count = pane.querySelector(".dap-count");
6234
6578
  const railCount = pane.querySelector(".dap-rail-count");
6235
6579
  const { waiting, blocked, total } = awaitBadgeStats(active);
@@ -6280,6 +6624,21 @@ function apply(ctx) {
6280
6624
  prevRenderedActiveIds = new Set(active.map((entry) => String(entry.id)));
6281
6625
  prevRenderedRecentIds = new Set(recent.map((entry) => String(entry.id)));
6282
6626
  }
6627
+ // 锚定补偿(R-01-021/AC-01):档位切换的渲染落地后量测当前选中卡的新位置并
6628
+ // 补偿 scrollTop,使当前卡顶部回到切换前相对视口的位置;补偿量超滚动边界时
6629
+ // 由浏览器钳制(以当前卡不滚出可视范围为准)。
6630
+ if (pendingDensityAnchor !== null) {
6631
+ const scrollEl = pane.querySelector(".dap-scroll");
6632
+ // 补偿按当前 DOM 的 data-current 卡实时定位:跨区迁移等渲染可能重建卡片
6633
+ // 元素,不持有旧引用(量测恒为 attached 节点)。
6634
+ const currentCard = scrollEl?.querySelector(".dap-card[data-current]") ?? null;
6635
+ if (scrollEl !== null && currentCard !== null) {
6636
+ const viewportTop = scrollEl.getBoundingClientRect().top;
6637
+ const shiftedTop = currentCard.getBoundingClientRect().top - viewportTop;
6638
+ scrollEl.scrollTop += shiftedTop - pendingDensityAnchor;
6639
+ }
6640
+ pendingDensityAnchor = null;
6641
+ }
6283
6642
  }
6284
6643
 
6285
6644
  // ---- 打开会话(让 sessions.open 自己校验列表,失败时 refresh + 重试) ----
@@ -6476,12 +6835,19 @@ function apply(ctx) {
6476
6835
  window.removeEventListener("pageshow", onBusyPageShow);
6477
6836
  document.removeEventListener("visibilitychange", onVisibilityResume);
6478
6837
  window.removeEventListener("pageshow", onPageShow);
6838
+ document.removeEventListener("visibilitychange", onSyncVisibilityResume);
6839
+ window.removeEventListener("pageshow", onSyncPageShow);
6479
6840
  completeAcksById.clear();
6480
6841
  busyById.clear();
6481
6842
  busyRequestedIds.clear();
6482
6843
  busyRetryAtById.clear();
6483
6844
  if (clockTimer !== null) clearInterval(clockTimer);
6484
6845
  if (recentTimeTimer !== null) clearInterval(recentTimeTimer);
6846
+ if (syncThrottleTimer !== null) clearTimeout(syncThrottleTimer);
6847
+ // 流式派生合并窗口:逐 detail 清理在途 timer(T-127)。
6848
+ for (const detail of sessionDetailsById.values()) {
6849
+ if (detail.logDeriveTimer) clearTimeout(detail.logDeriveTimer);
6850
+ }
6485
6851
  if (e2eListReleaseTimer !== null) clearTimeout(e2eListReleaseTimer);
6486
6852
  for (const [timer, resolve] of e2eModelDelayWaiters) {
6487
6853
  clearTimeout(timer);